Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ independently — `update-maven-wrapper.yml`'s `versions` job, `maven-wrapper-pr
own `cmp` (now a re-export of this module), and what would otherwise be a fourth copy for the
Antora UI bundle's release tags in `antora-ui-bundle.js`.

## `dependabot-ignore.js`

Reads the `ignore` rules of a parsed `.github/dependabot.yml` so `update-maven-wrapper.yml` can
hold a branch on the Maven versions Dependabot has been told to leave alone.
`pickMavenTarget(config, { branch, defaultBranch, current, candidates, warn })` returns the newest
candidate (ascending list) not ignored for that branch plus the newer ones it skipped and why;
`findMavenIgnore` answers the same for one version, and `parseRange` turns a `versions` string
(comparators, Maven intervals, `3.x` wildcards) into a predicate. Pure functions — fetching and
YAML parsing (`yq`) stay in the workflow — and anything unreadable matches nothing.

## `gh-cli.js`

`gh(args)`, `ghRetry(args, attempts)`, `ghJson(path, method, payload)`: the `execFileSync('gh',
Expand Down
115 changes: 115 additions & 0 deletions .github/scripts/__tests__/dependabot-ignore.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
const { findMavenIgnore, pickMavenTarget, parseRange } = require('../dependabot-ignore');

const NAME = 'org.apache.maven:apache-maven';
const cfg = (ignore, extra = {}) => ({
version: 2,
updates: [{ 'package-ecosystem': 'maven', directory: '/', 'target-branch': '3.2.x', ignore, ...extra }],
});
const ctx = { branch: '3.2.x', defaultBranch: 'main', current: '3.9.16' };
const ignored = (config, candidate, over = {}) =>
findMavenIgnore(config, { ...ctx, candidate, ...over });

describe('parseRange', () => {
it.each([
['>=3.10.0', '3.10.0', true], ['>=3.10.0', '3.9.16', false],
['> 3.9', '3.9.1', true], ['<4', '3.10.0', true], ['<4', '4.0.0', false],
['<=3.9.9', '3.9.9', true], ['=3.9.1', '3.9.1', true], ['=3.9.1', '3.9.2', false],
['>=3.9, <3.10', '3.9.16', true], ['>=3.9, <3.10', '3.10.0', false],
['[3.10,)', '3.10.0', true], ['[3.10,)', '3.9.16', false],
['(,4.0)', '3.99.0', true], ['[3.9,3.10)', '3.10.0', false],
['[3.9,3.10),[4,5)', '4.1.0', true],
['3.x', '3.10.0', true], ['3.x', '4.0.0', false], ['3.10.*', '3.10.1', true],
['3.10.*', '3.9.9', false], ['~> 3.9', '3.10.0', true], ['~> 3.9.1', '3.9.9', true],
['~> 3.9.1', '3.10.0', false],
])('%s vs %s', (range, version, expected) => {
expect(parseRange(range)(version)).toBe(expected);
});

it.each(['', 'latest', '>=abc', '[1,2', '^3.9'])('rejects %j', range => {
expect(parseRange(range)).toBeNull();
});
});

describe('findMavenIgnore', () => {
const rule = { 'dependency-name': NAME, versions: ['>=3.10.0'] };

it('ignores a version inside the range on the matching branch', () => {
expect(ignored(cfg([rule]), '3.10.0')).toMatch(/versions '>=3\.10\.0'/);
});

it('allows a version outside the range', () => {
expect(ignored(cfg([rule]), '3.9.17')).toBeNull();
});

it('does not apply to a different target-branch', () => {
expect(ignored(cfg([rule]), '3.10.0', { branch: '4.3.x' })).toBeNull();
});

it('applies an entry with no target-branch to the default branch only', () => {
const config = cfg([rule], { 'target-branch': undefined });
expect(ignored(config, '3.10.0', { branch: 'main' })).not.toBeNull();
expect(ignored(config, '3.10.0', { branch: '3.2.x' })).toBeNull();
});

it('ignores every version when the rule has no versions or update-types', () => {
expect(ignored(cfg([{ 'dependency-name': NAME }]), '3.9.17')).toMatch(/all versions/);
});

it('honours update-types against the current version', () => {
const minor = cfg([{ 'dependency-name': NAME, 'update-types': ['version-update:semver-minor'] }]);
expect(ignored(minor, '3.10.0')).toMatch(/semver-minor/);
expect(ignored(minor, '3.9.17')).toBeNull();
expect(ignored(minor, '4.0.0')).toBeNull();
const major = cfg([{ 'dependency-name': NAME, 'update-types': ['version-update:semver-major'] }]);
expect(ignored(major, '4.0.0')).not.toBeNull();
});

it('cannot apply update-types without a current version', () => {
const minor = cfg([{ 'dependency-name': NAME, 'update-types': ['version-update:semver-minor'] }]);
expect(ignored(minor, '3.10.0', { current: null })).toBeNull();
});

it('ignores other dependencies, wildcard names match', () => {
expect(ignored(cfg([{ 'dependency-name': 'org.apache.maven:maven-core' }]), '3.10.0')).toBeNull();
expect(ignored(cfg([{ 'dependency-name': 'org.apache.maven:*' }]), '3.10.0')).not.toBeNull();
});

it('only reads maven entries that cover the root', () => {
expect(ignored(cfg([rule], { 'package-ecosystem': 'github-actions' }), '3.10.0')).toBeNull();
expect(ignored(cfg([rule], { directory: '/sub' }), '3.10.0')).toBeNull();
expect(ignored(cfg([rule], { directory: undefined, directories: ['/sub', '/'] }), '3.10.0'))
.not.toBeNull();
});

it('matches nothing, and warns, on an unreadable range', () => {
const warn = jest.fn();
expect(ignored(cfg([{ 'dependency-name': NAME, versions: ['^3.10'] }]), '3.10.0', { warn })).toBeNull();
expect(warn).toHaveBeenCalledWith(expect.stringContaining('^3.10'));
});

it.each([null, undefined, {}, { updates: null }])('tolerates config %j', config => {
expect(ignored(config, '3.10.0')).toBeNull();
});
});

describe('pickMavenTarget', () => {
const candidates = ['3.9.16', '3.9.17', '3.10.0'];
const rule = { 'dependency-name': NAME, versions: ['>=3.10.0'] };

it('falls back to the newest version that is not ignored', () => {
const r = pickMavenTarget(cfg([rule]), { ...ctx, candidates });
expect(r.target).toBe('3.9.17');
expect(r.skipped.map(s => s.version)).toEqual(['3.10.0']);
});

it('picks the newest when nothing is ignored for the branch', () => {
const r = pickMavenTarget(cfg([rule]), { ...ctx, branch: '4.3.x', candidates });
expect(r).toEqual({ target: '3.10.0', skipped: [] });
});

it('returns a null target when every candidate is ignored', () => {
const r = pickMavenTarget(cfg([{ 'dependency-name': NAME }]), { ...ctx, candidates });
expect(r.target).toBeNull();
expect(r.skipped).toHaveLength(3);
});
});
139 changes: 139 additions & 0 deletions .github/scripts/dependabot-ignore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
'use strict';

const { cmp } = require('./version-cmp');

// Reads the `ignore` rules of a parsed .github/dependabot.yml so update-maven-wrapper.yml can
// hold a branch back on the same Maven versions Dependabot has been told to leave alone. A
// branch pinned there on purpose (a plugin that breaks on the newer Maven, say) would otherwise
// get a wrapper PR from this workflow that Dependabot itself would never raise.
//
// Pure functions over the parsed config - fetching and YAML parsing stay in the workflow.
// Anything this module cannot interpret matches nothing: an unreadable rule must never stop a
// branch from being updated.

const MAVEN_DEPENDENCY = 'org.apache.maven:apache-maven';

const isVersion = s => /^\d+(\.\d+)*$/.test(s);

// `*` is the only wildcard dependabot.yml allows in a dependency-name.
function nameMatches(pattern, name) {
if (typeof pattern !== 'string') return false;
const re = new RegExp('^' + pattern.split('*').map(p => p.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$');
return re.test(name);
}

// Does the entry's directory (or directories) cover the repository root, where the wrapper
// the workflow edits lives? A missing directory is read as the root rather than as "nothing".
function coversRoot(entry) {
const dirs = [].concat(entry.directory || [], entry.directories || []);
if (!dirs.length) return true;
return dirs.some(d => ['/', '/*', '/**', '**', '*'].includes(String(d).trim()));
}

// One Dependabot `versions` string, as a predicate over a candidate version - or null when the
// form is not one of those handled. Handled:
// >=3.10.0 > 3 <4 <=3.9.9 =3.9.1 ~> 3.9 (comma/space separated: all must hold)
// [3.10,) (,4.0) [3.9,3.10) (Maven intervals, comma-joined: any may hold)
// 3.x 3.10.* (prefix wildcards)
function parseRange(range) {
const text = String(range).trim();
if (!text) return null;

if (/^[[(]/.test(text)) {
const intervals = text.match(/[[(][^[\]()]*[\])]/g);
if (!intervals || intervals.join(',').replace(/\s/g, '') !== text.replace(/\s/g, '')) return null;
const preds = intervals.map(iv => {
const m = /^([[(])\s*([^,\s]*)\s*,\s*([^,\s]*)\s*([\])])$/.exec(iv);
if (!m) return null;
const [, open, lo, hi, close] = m;
if ((lo && !isVersion(lo)) || (hi && !isVersion(hi))) return null;
return v => (!lo || (open === '[' ? cmp(v, lo) >= 0 : cmp(v, lo) > 0))
&& (!hi || (close === ']' ? cmp(v, hi) <= 0 : cmp(v, hi) < 0));
});
return preds.includes(null) ? null : v => preds.some(p => p(v));
}

const wild = /^(\d+(?:\.\d+)*)\.(?:x|\*)$/i.exec(text);
if (wild) {
const prefix = wild[1].split('.');
return v => {
const parts = v.split('.');
return prefix.every((p, i) => Number(parts[i]) === Number(p));
};
}

// Normalise "> = 3" style spacing, then split into comparators.
const tokens = text.replace(/\s*(>=|<=|~>|>|<|=)\s*/g, ' $1').split(/[\s,]+/).filter(Boolean);
const preds = [];
for (const t of tokens) {
const m = /^(>=|<=|~>|>|<|=)?(\d+(?:\.\d+)*)$/.exec(t);
if (!m) return null;
const [, op = '=', ver] = m;
if (op === '>=') preds.push(v => cmp(v, ver) >= 0);
else if (op === '>') preds.push(v => cmp(v, ver) > 0);
else if (op === '<=') preds.push(v => cmp(v, ver) <= 0);
else if (op === '<') preds.push(v => cmp(v, ver) < 0);
else if (op === '=') preds.push(v => cmp(v, ver) === 0);
else {
// Pessimistic: ~> 3.9 means >= 3.9 and < 4; ~> 3.9.1 means >= 3.9.1 and < 3.10.
const parts = ver.split('.').map(Number);
const upper = parts.length > 1 ? [...parts.slice(0, -2), parts[parts.length - 2] + 1] : [parts[0] + 1];
preds.push(v => cmp(v, ver) >= 0 && cmp(v, upper.join('.')) < 0);
}
}
return preds.length ? v => preds.every(p => p(v)) : null;
}

// The size of the jump from `current` to `candidate`, as Dependabot names it.
function updateType(current, candidate) {
if (!current || !isVersion(current) || !isVersion(candidate)) return null;
const a = current.split('.').map(Number), b = candidate.split('.').map(Number);
if ((a[0] || 0) !== (b[0] || 0)) return 'version-update:semver-major';
if ((a[1] || 0) !== (b[1] || 0)) return 'version-update:semver-minor';
return 'version-update:semver-patch';
}

// The first ignore rule that excludes `candidate` for this branch, as a human-readable string,
// or null when none does. `warn` is told about `versions` strings that could not be read.
function findMavenIgnore(config, { branch, defaultBranch, current, candidate, warn = () => {} }) {
const updates = config && Array.isArray(config.updates) ? config.updates : [];
for (const entry of updates) {
if (!entry || entry['package-ecosystem'] !== 'maven' || !coversRoot(entry)) continue;
if ((entry['target-branch'] || defaultBranch) !== branch) continue;

for (const rule of Array.isArray(entry.ignore) ? entry.ignore : []) {
if (!rule || !nameMatches(rule['dependency-name'], MAVEN_DEPENDENCY)) continue;
const versions = [].concat(rule.versions || []);
const types = [].concat(rule['update-types'] || []);
const where = `${rule['dependency-name']} ignore in dependabot.yml`
+ (entry['target-branch'] ? ` (target-branch ${entry['target-branch']})` : '');

// A rule with neither field ignores the dependency outright.
if (!versions.length && !types.length) return `${where}: all versions`;

for (const range of versions) {
const pred = parseRange(range);
if (!pred) { warn(`unrecognised ignore versions '${range}' - not applied`); continue; }
if (pred(candidate)) return `${where}: versions '${range}'`;
}
const type = updateType(current, candidate);
if (type && types.includes(type)) return `${where}: update-types '${type}'`;
}
}
return null;
}

// The newest candidate Dependabot has not been told to ignore for this branch. `candidates` is
// ascending. `skipped` lists the newer ones passed over, so the summary can say why a branch is
// not on the newest Maven. `target` is null when every candidate is ignored.
function pickMavenTarget(config, { branch, defaultBranch, current, candidates, warn }) {
const skipped = [];
for (let i = candidates.length - 1; i >= 0; i--) {
const reason = findMavenIgnore(config, { branch, defaultBranch, current, candidate: candidates[i], warn });
if (!reason) return { target: candidates[i], skipped };
skipped.push({ version: candidates[i], reason });
}
return { target: null, skipped };
}

module.exports = { findMavenIgnore, pickMavenTarget, parseRange };
36 changes: 36 additions & 0 deletions .github/workflows/README-update-maven-wrapper.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,41 @@ Central's `<latest>` — which is currently `4.0.0-rc-6`. Dependabot itself stay
line (its logs show *"Filtered out 33 pre-release versions"*), so tracking the stable 3.x line is what
actually keeps it quiet. Set `maven_version` explicitly to move to a 4.x line deliberately.

### Respecting Dependabot ignores

The newest 3.x is not always right for every branch: a branch can depend on a plugin that breaks on
a newer Maven (Maven 3.10 made Maven Resolver reject cached artifacts whose origin repository is
not in the request, which breaks `kotlin-maven-plugin` 1.6.x). The usual fix is an `ignore` for
`org.apache.maven:apache-maven` under that branch's `target-branch` in `.github/dependabot.yml`,
and this workflow reads the same rule so it does not open a PR Dependabot would never raise:

```yaml
- package-ecosystem: maven
directory: /
target-branch: 3.2.x
ignore:
- dependency-name: "org.apache.maven:apache-maven"
versions: [">=3.10.0"]
```

For each branch the workflow reads `dependabot.yml` from the repository's **default branch** (where
Dependabot reads it), keeps the `maven` entries whose `target-branch` is that branch (or, with none
set, the default branch) and whose `directory`/`directories` cover `/`, and moves the branch to the
**newest stable 3.x that is not ignored**. In the example, `3.2.x` goes to the newest 3.9.x while
every other branch goes to 3.10.0. If every version is ignored the branch is reported as
`ignored-by-dependabot` and nothing is opened.

Supported in `versions`: comparators (`>=3.10.0`, `>3`, `<4`, `<=3.9.9`, `=3.9.1`, `~> 3.9`, several
joined by commas), Maven intervals (`[3.10,)`, `(,4.0)`), and wildcards (`3.x`, `3.10.*`).
`update-types` (`version-update:semver-major|minor|patch`) is judged against the root wrapper's
current Maven, and a rule with neither field ignores everything. A range in any other form is
logged and ignored.

It fails open. A missing `dependabot.yml`, an API failure, or a file that will not parse means no
restriction, so the branch is updated as before. An already-open wrapper PR for an ignored version
is left as it is and reported as `ignored-by-dependabot` — close it by hand. An explicit
`maven_version` still goes through the same check.

> Verify any version you pin by hand actually exists. `3.9.19` looks plausible and does not
> exist — pointing `distributionUrl` at it would 404 on every build.

Expand Down Expand Up @@ -398,6 +433,7 @@ Set `auto_merge` to false to only open and update PRs and leave merging to a hum
| `branch-exists` | The branch exists with no open PR — a previous PR was closed unmerged, so it is **left alone** rather than reopened |
| `up-to-date` | Already on the target |
| `ahead` | Newer than the target; never walked backwards |
| `ignored-by-dependabot` | `dependabot.yml` ignores the newer Maven version(s) for this branch, so it stays where it is (or every version is ignored); an open PR for an ignored version is left for a human to close — see [Respecting Dependabot ignores](#respecting-dependabot-ignores) |
| `no-wrapper` | No `maven-wrapper.properties` in any directory with a `pom.xml` |
| `unparsed` | No `distributionUrl` on the branch matched the expected shape — needs a look |
| `check-ok` | `check_only` — every wrapper file is readable by Dependabot |
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/test-shared-scripts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ jobs:
- workflow: update-maven-wrapper.yml
min-blocks: 5
exports: >-
gh-cli.js:ghRetry|
gh-cli.js:gh,ghRetry|
dependabot-ignore.js:pickMavenTarget|
git-pr-helpers.js:readFileAt,createBranch,commitFilesToRef,findOpenPrByHeadPrefix,openPr|
merge-if-green.js:mergeIfGreen|
project-branch-matrix.js:buildBranchMatrix|
Expand Down
Loading
Loading