Skip to content

Commit 1b978cf

Browse files
committed
Update maven-wrapper to check dependabot
Update comment
1 parent cea1560 commit 1b978cf

6 files changed

Lines changed: 404 additions & 7 deletions

File tree

‎.github/scripts/README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,16 @@ independently — `update-maven-wrapper.yml`'s `versions` job, `maven-wrapper-pr
8686
own `cmp` (now a re-export of this module), and what would otherwise be a fourth copy for the
8787
Antora UI bundle's release tags in `antora-ui-bundle.js`.
8888

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

91101
`gh(args)`, `ghRetry(args, attempts)`, `ghJson(path, method, payload)`: the `execFileSync('gh',
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
const { findMavenIgnore, pickMavenTarget, parseRange } = require('../dependabot-ignore');
2+
3+
const NAME = 'org.apache.maven:apache-maven';
4+
const cfg = (ignore, extra = {}) => ({
5+
version: 2,
6+
updates: [{ 'package-ecosystem': 'maven', directory: '/', 'target-branch': '3.2.x', ignore, ...extra }],
7+
});
8+
const ctx = { branch: '3.2.x', defaultBranch: 'main', current: '3.9.16' };
9+
const ignored = (config, candidate, over = {}) =>
10+
findMavenIgnore(config, { ...ctx, candidate, ...over });
11+
12+
describe('parseRange', () => {
13+
it.each([
14+
['>=3.10.0', '3.10.0', true], ['>=3.10.0', '3.9.16', false],
15+
['> 3.9', '3.9.1', true], ['<4', '3.10.0', true], ['<4', '4.0.0', false],
16+
['<=3.9.9', '3.9.9', true], ['=3.9.1', '3.9.1', true], ['=3.9.1', '3.9.2', false],
17+
['>=3.9, <3.10', '3.9.16', true], ['>=3.9, <3.10', '3.10.0', false],
18+
['[3.10,)', '3.10.0', true], ['[3.10,)', '3.9.16', false],
19+
['(,4.0)', '3.99.0', true], ['[3.9,3.10)', '3.10.0', false],
20+
['[3.9,3.10),[4,5)', '4.1.0', true],
21+
['3.x', '3.10.0', true], ['3.x', '4.0.0', false], ['3.10.*', '3.10.1', true],
22+
['3.10.*', '3.9.9', false], ['~> 3.9', '3.10.0', true], ['~> 3.9.1', '3.9.9', true],
23+
['~> 3.9.1', '3.10.0', false],
24+
])('%s vs %s', (range, version, expected) => {
25+
expect(parseRange(range)(version)).toBe(expected);
26+
});
27+
28+
it.each(['', 'latest', '>=abc', '[1,2', '^3.9'])('rejects %j', range => {
29+
expect(parseRange(range)).toBeNull();
30+
});
31+
});
32+
33+
describe('findMavenIgnore', () => {
34+
const rule = { 'dependency-name': NAME, versions: ['>=3.10.0'] };
35+
36+
it('ignores a version inside the range on the matching branch', () => {
37+
expect(ignored(cfg([rule]), '3.10.0')).toMatch(/versions '>=3\.10\.0'/);
38+
});
39+
40+
it('allows a version outside the range', () => {
41+
expect(ignored(cfg([rule]), '3.9.17')).toBeNull();
42+
});
43+
44+
it('does not apply to a different target-branch', () => {
45+
expect(ignored(cfg([rule]), '3.10.0', { branch: '4.3.x' })).toBeNull();
46+
});
47+
48+
it('applies an entry with no target-branch to the default branch only', () => {
49+
const config = cfg([rule], { 'target-branch': undefined });
50+
expect(ignored(config, '3.10.0', { branch: 'main' })).not.toBeNull();
51+
expect(ignored(config, '3.10.0', { branch: '3.2.x' })).toBeNull();
52+
});
53+
54+
it('ignores every version when the rule has no versions or update-types', () => {
55+
expect(ignored(cfg([{ 'dependency-name': NAME }]), '3.9.17')).toMatch(/all versions/);
56+
});
57+
58+
it('honours update-types against the current version', () => {
59+
const minor = cfg([{ 'dependency-name': NAME, 'update-types': ['version-update:semver-minor'] }]);
60+
expect(ignored(minor, '3.10.0')).toMatch(/semver-minor/);
61+
expect(ignored(minor, '3.9.17')).toBeNull();
62+
expect(ignored(minor, '4.0.0')).toBeNull();
63+
const major = cfg([{ 'dependency-name': NAME, 'update-types': ['version-update:semver-major'] }]);
64+
expect(ignored(major, '4.0.0')).not.toBeNull();
65+
});
66+
67+
it('cannot apply update-types without a current version', () => {
68+
const minor = cfg([{ 'dependency-name': NAME, 'update-types': ['version-update:semver-minor'] }]);
69+
expect(ignored(minor, '3.10.0', { current: null })).toBeNull();
70+
});
71+
72+
it('ignores other dependencies, wildcard names match', () => {
73+
expect(ignored(cfg([{ 'dependency-name': 'org.apache.maven:maven-core' }]), '3.10.0')).toBeNull();
74+
expect(ignored(cfg([{ 'dependency-name': 'org.apache.maven:*' }]), '3.10.0')).not.toBeNull();
75+
});
76+
77+
it('only reads maven entries that cover the root', () => {
78+
expect(ignored(cfg([rule], { 'package-ecosystem': 'github-actions' }), '3.10.0')).toBeNull();
79+
expect(ignored(cfg([rule], { directory: '/sub' }), '3.10.0')).toBeNull();
80+
expect(ignored(cfg([rule], { directory: undefined, directories: ['/sub', '/'] }), '3.10.0'))
81+
.not.toBeNull();
82+
});
83+
84+
it('matches nothing, and warns, on an unreadable range', () => {
85+
const warn = jest.fn();
86+
expect(ignored(cfg([{ 'dependency-name': NAME, versions: ['^3.10'] }]), '3.10.0', { warn })).toBeNull();
87+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('^3.10'));
88+
});
89+
90+
it.each([null, undefined, {}, { updates: null }])('tolerates config %j', config => {
91+
expect(ignored(config, '3.10.0')).toBeNull();
92+
});
93+
});
94+
95+
describe('pickMavenTarget', () => {
96+
const candidates = ['3.9.16', '3.9.17', '3.10.0'];
97+
const rule = { 'dependency-name': NAME, versions: ['>=3.10.0'] };
98+
99+
it('falls back to the newest version that is not ignored', () => {
100+
const r = pickMavenTarget(cfg([rule]), { ...ctx, candidates });
101+
expect(r.target).toBe('3.9.17');
102+
expect(r.skipped.map(s => s.version)).toEqual(['3.10.0']);
103+
});
104+
105+
it('picks the newest when nothing is ignored for the branch', () => {
106+
const r = pickMavenTarget(cfg([rule]), { ...ctx, branch: '4.3.x', candidates });
107+
expect(r).toEqual({ target: '3.10.0', skipped: [] });
108+
});
109+
110+
it('returns a null target when every candidate is ignored', () => {
111+
const r = pickMavenTarget(cfg([{ 'dependency-name': NAME }]), { ...ctx, candidates });
112+
expect(r.target).toBeNull();
113+
expect(r.skipped).toHaveLength(3);
114+
});
115+
});
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
'use strict';
2+
3+
const { cmp } = require('./version-cmp');
4+
5+
// Reads the `ignore` rules of a parsed .github/dependabot.yml so update-maven-wrapper.yml can
6+
// hold a branch back on the same Maven versions Dependabot has been told to leave alone. A
7+
// branch pinned there on purpose (a plugin that breaks on the newer Maven, say) would otherwise
8+
// get a wrapper PR from this workflow that Dependabot itself would never raise.
9+
//
10+
// Pure functions over the parsed config - fetching and YAML parsing stay in the workflow.
11+
// Anything this module cannot interpret matches nothing: an unreadable rule must never stop a
12+
// branch from being updated.
13+
14+
const MAVEN_DEPENDENCY = 'org.apache.maven:apache-maven';
15+
16+
const isVersion = s => /^\d+(\.\d+)*$/.test(s);
17+
18+
// `*` is the only wildcard dependabot.yml allows in a dependency-name.
19+
function nameMatches(pattern, name) {
20+
if (typeof pattern !== 'string') return false;
21+
const re = new RegExp('^' + pattern.split('*').map(p => p.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$');
22+
return re.test(name);
23+
}
24+
25+
// Does the entry's directory (or directories) cover the repository root, where the wrapper
26+
// the workflow edits lives? A missing directory is read as the root rather than as "nothing".
27+
function coversRoot(entry) {
28+
const dirs = [].concat(entry.directory || [], entry.directories || []);
29+
if (!dirs.length) return true;
30+
return dirs.some(d => ['/', '/*', '/**', '**', '*'].includes(String(d).trim()));
31+
}
32+
33+
// One Dependabot `versions` string, as a predicate over a candidate version - or null when the
34+
// form is not one of those handled. Handled:
35+
// >=3.10.0 > 3 <4 <=3.9.9 =3.9.1 ~> 3.9 (comma/space separated: all must hold)
36+
// [3.10,) (,4.0) [3.9,3.10) (Maven intervals, comma-joined: any may hold)
37+
// 3.x 3.10.* (prefix wildcards)
38+
function parseRange(range) {
39+
const text = String(range).trim();
40+
if (!text) return null;
41+
42+
if (/^[[(]/.test(text)) {
43+
const intervals = text.match(/[[(][^[\]()]*[\])]/g);
44+
if (!intervals || intervals.join(',').replace(/\s/g, '') !== text.replace(/\s/g, '')) return null;
45+
const preds = intervals.map(iv => {
46+
const m = /^([[(])\s*([^,\s]*)\s*,\s*([^,\s]*)\s*([\])])$/.exec(iv);
47+
if (!m) return null;
48+
const [, open, lo, hi, close] = m;
49+
if ((lo && !isVersion(lo)) || (hi && !isVersion(hi))) return null;
50+
return v => (!lo || (open === '[' ? cmp(v, lo) >= 0 : cmp(v, lo) > 0))
51+
&& (!hi || (close === ']' ? cmp(v, hi) <= 0 : cmp(v, hi) < 0));
52+
});
53+
return preds.includes(null) ? null : v => preds.some(p => p(v));
54+
}
55+
56+
const wild = /^(\d+(?:\.\d+)*)\.(?:x|\*)$/i.exec(text);
57+
if (wild) {
58+
const prefix = wild[1].split('.');
59+
return v => {
60+
const parts = v.split('.');
61+
return prefix.every((p, i) => Number(parts[i]) === Number(p));
62+
};
63+
}
64+
65+
// Normalise "> = 3" style spacing, then split into comparators.
66+
const tokens = text.replace(/\s*(>=|<=|~>|>|<|=)\s*/g, ' $1').split(/[\s,]+/).filter(Boolean);
67+
const preds = [];
68+
for (const t of tokens) {
69+
const m = /^(>=|<=|~>|>|<|=)?(\d+(?:\.\d+)*)$/.exec(t);
70+
if (!m) return null;
71+
const [, op = '=', ver] = m;
72+
if (op === '>=') preds.push(v => cmp(v, ver) >= 0);
73+
else if (op === '>') preds.push(v => cmp(v, ver) > 0);
74+
else if (op === '<=') preds.push(v => cmp(v, ver) <= 0);
75+
else if (op === '<') preds.push(v => cmp(v, ver) < 0);
76+
else if (op === '=') preds.push(v => cmp(v, ver) === 0);
77+
else {
78+
// Pessimistic: ~> 3.9 means >= 3.9 and < 4; ~> 3.9.1 means >= 3.9.1 and < 3.10.
79+
const parts = ver.split('.').map(Number);
80+
const upper = parts.length > 1 ? [...parts.slice(0, -2), parts[parts.length - 2] + 1] : [parts[0] + 1];
81+
preds.push(v => cmp(v, ver) >= 0 && cmp(v, upper.join('.')) < 0);
82+
}
83+
}
84+
return preds.length ? v => preds.every(p => p(v)) : null;
85+
}
86+
87+
// The size of the jump from `current` to `candidate`, as Dependabot names it.
88+
function updateType(current, candidate) {
89+
if (!current || !isVersion(current) || !isVersion(candidate)) return null;
90+
const a = current.split('.').map(Number), b = candidate.split('.').map(Number);
91+
if ((a[0] || 0) !== (b[0] || 0)) return 'version-update:semver-major';
92+
if ((a[1] || 0) !== (b[1] || 0)) return 'version-update:semver-minor';
93+
return 'version-update:semver-patch';
94+
}
95+
96+
// The first ignore rule that excludes `candidate` for this branch, as a human-readable string,
97+
// or null when none does. `warn` is told about `versions` strings that could not be read.
98+
function findMavenIgnore(config, { branch, defaultBranch, current, candidate, warn = () => {} }) {
99+
const updates = config && Array.isArray(config.updates) ? config.updates : [];
100+
for (const entry of updates) {
101+
if (!entry || entry['package-ecosystem'] !== 'maven' || !coversRoot(entry)) continue;
102+
if ((entry['target-branch'] || defaultBranch) !== branch) continue;
103+
104+
for (const rule of Array.isArray(entry.ignore) ? entry.ignore : []) {
105+
if (!rule || !nameMatches(rule['dependency-name'], MAVEN_DEPENDENCY)) continue;
106+
const versions = [].concat(rule.versions || []);
107+
const types = [].concat(rule['update-types'] || []);
108+
const where = `${rule['dependency-name']} ignore in dependabot.yml`
109+
+ (entry['target-branch'] ? ` (target-branch ${entry['target-branch']})` : '');
110+
111+
// A rule with neither field ignores the dependency outright.
112+
if (!versions.length && !types.length) return `${where}: all versions`;
113+
114+
for (const range of versions) {
115+
const pred = parseRange(range);
116+
if (!pred) { warn(`unrecognised ignore versions '${range}' - not applied`); continue; }
117+
if (pred(candidate)) return `${where}: versions '${range}'`;
118+
}
119+
const type = updateType(current, candidate);
120+
if (type && types.includes(type)) return `${where}: update-types '${type}'`;
121+
}
122+
}
123+
return null;
124+
}
125+
126+
// The newest candidate Dependabot has not been told to ignore for this branch. `candidates` is
127+
// ascending. `skipped` lists the newer ones passed over, so the summary can say why a branch is
128+
// not on the newest Maven. `target` is null when every candidate is ignored.
129+
function pickMavenTarget(config, { branch, defaultBranch, current, candidates, warn }) {
130+
const skipped = [];
131+
for (let i = candidates.length - 1; i >= 0; i--) {
132+
const reason = findMavenIgnore(config, { branch, defaultBranch, current, candidate: candidates[i], warn });
133+
if (!reason) return { target: candidates[i], skipped };
134+
skipped.push({ version: candidates[i], reason });
135+
}
136+
return { target: null, skipped };
137+
}
138+
139+
module.exports = { findMavenIgnore, pickMavenTarget, parseRange };

‎.github/workflows/README-update-maven-wrapper.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,41 @@ Central's `<latest>` — which is currently `4.0.0-rc-6`. Dependabot itself stay
289289
line (its logs show *"Filtered out 33 pre-release versions"*), so tracking the stable 3.x line is what
290290
actually keeps it quiet. Set `maven_version` explicitly to move to a 4.x line deliberately.
291291

292+
### Respecting Dependabot ignores
293+
294+
The newest 3.x is not always right for every branch: a branch can depend on a plugin that breaks on
295+
a newer Maven (Maven 3.10 made Maven Resolver reject cached artifacts whose origin repository is
296+
not in the request, which breaks `kotlin-maven-plugin` 1.6.x). The usual fix is an `ignore` for
297+
`org.apache.maven:apache-maven` under that branch's `target-branch` in `.github/dependabot.yml`,
298+
and this workflow reads the same rule so it does not open a PR Dependabot would never raise:
299+
300+
```yaml
301+
- package-ecosystem: maven
302+
directory: /
303+
target-branch: 3.2.x
304+
ignore:
305+
- dependency-name: "org.apache.maven:apache-maven"
306+
versions: [">=3.10.0"]
307+
```
308+
309+
For each branch the workflow reads `dependabot.yml` from the repository's **default branch** (where
310+
Dependabot reads it), keeps the `maven` entries whose `target-branch` is that branch (or, with none
311+
set, the default branch) and whose `directory`/`directories` cover `/`, and moves the branch to the
312+
**newest stable 3.x that is not ignored**. In the example, `3.2.x` goes to the newest 3.9.x while
313+
every other branch goes to 3.10.0. If every version is ignored the branch is reported as
314+
`ignored-by-dependabot` and nothing is opened.
315+
316+
Supported in `versions`: comparators (`>=3.10.0`, `>3`, `<4`, `<=3.9.9`, `=3.9.1`, `~> 3.9`, several
317+
joined by commas), Maven intervals (`[3.10,)`, `(,4.0)`), and wildcards (`3.x`, `3.10.*`).
318+
`update-types` (`version-update:semver-major|minor|patch`) is judged against the root wrapper's
319+
current Maven, and a rule with neither field ignores everything. A range in any other form is
320+
logged and ignored.
321+
322+
It fails open. A missing `dependabot.yml`, an API failure, or a file that will not parse means no
323+
restriction, so the branch is updated as before. An already-open wrapper PR for an ignored version
324+
is left as it is and reported as `ignored-by-dependabot` — close it by hand. An explicit
325+
`maven_version` still goes through the same check.
326+
292327
> Verify any version you pin by hand actually exists. `3.9.19` looks plausible and does not
293328
> exist — pointing `distributionUrl` at it would 404 on every build.
294329

@@ -398,6 +433,7 @@ Set `auto_merge` to false to only open and update PRs and leave merging to a hum
398433
| `branch-exists` | The branch exists with no open PR — a previous PR was closed unmerged, so it is **left alone** rather than reopened |
399434
| `up-to-date` | Already on the target |
400435
| `ahead` | Newer than the target; never walked backwards |
436+
| `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) |
401437
| `no-wrapper` | No `maven-wrapper.properties` in any directory with a `pom.xml` |
402438
| `unparsed` | No `distributionUrl` on the branch matched the expected shape — needs a look |
403439
| `check-ok` | `check_only` — every wrapper file is readable by Dependabot |

‎.github/workflows/test-shared-scripts.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ jobs:
7575
- workflow: update-maven-wrapper.yml
7676
min-blocks: 5
7777
exports: >-
78-
gh-cli.js:ghRetry|
78+
gh-cli.js:gh,ghRetry|
79+
dependabot-ignore.js:pickMavenTarget|
7980
git-pr-helpers.js:readFileAt,createBranch,commitFilesToRef,findOpenPrByHeadPrefix,openPr|
8081
merge-if-green.js:mergeIfGreen|
8182
project-branch-matrix.js:buildBranchMatrix|

0 commit comments

Comments
 (0)