diff --git a/.github/scripts/README.md b/.github/scripts/README.md index aa1a511..2ee0d7c 100644 --- a/.github/scripts/README.md +++ b/.github/scripts/README.md @@ -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', diff --git a/.github/scripts/__tests__/dependabot-ignore.test.js b/.github/scripts/__tests__/dependabot-ignore.test.js new file mode 100644 index 0000000..3656646 --- /dev/null +++ b/.github/scripts/__tests__/dependabot-ignore.test.js @@ -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); + }); +}); diff --git a/.github/scripts/dependabot-ignore.js b/.github/scripts/dependabot-ignore.js new file mode 100644 index 0000000..07ffe4e --- /dev/null +++ b/.github/scripts/dependabot-ignore.js @@ -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 }; diff --git a/.github/workflows/README-update-maven-wrapper.md b/.github/workflows/README-update-maven-wrapper.md index 456d4e6..04fc954 100644 --- a/.github/workflows/README-update-maven-wrapper.md +++ b/.github/workflows/README-update-maven-wrapper.md @@ -289,6 +289,41 @@ Central's `` — 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. @@ -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 | diff --git a/.github/workflows/test-shared-scripts.yml b/.github/workflows/test-shared-scripts.yml index d17a660..1e047a5 100644 --- a/.github/workflows/test-shared-scripts.yml +++ b/.github/workflows/test-shared-scripts.yml @@ -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| diff --git a/.github/workflows/update-maven-wrapper.yml b/.github/workflows/update-maven-wrapper.yml index 89e8164..52f627c 100644 --- a/.github/workflows/update-maven-wrapper.yml +++ b/.github/workflows/update-maven-wrapper.yml @@ -131,6 +131,7 @@ jobs: runs-on: ubuntu-latest outputs: maven: ${{ steps.resolve.outputs.maven }} + maven-candidates: ${{ steps.resolve.outputs.maven-candidates }} wrapper: ${{ steps.resolve.outputs.wrapper }} steps: # For .github/scripts/version-cmp.js, the dotted-numeric comparator shared with @@ -165,12 +166,15 @@ jobs: // Maven Central's is currently a 4.0.0 release candidate, and Dependabot // itself stays on the stable line ("Filtered out 33 pre-release versions"), so the // target is the newest stable 3.x rather than whatever happens to say. + // Every stable 3.x is kept, not just the newest: a branch Dependabot is told to hold + // back (an ignore in dependabot.yml) moves to the newest one it has not ignored. let maven = (process.env.MAVEN_INPUT || '').trim(); + let candidates = maven ? [maven] : []; if (!maven) { const all = versionsIn(fetchMeta( 'https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/maven-metadata.xml')); - const stable = all.filter(v => /^3\.\d+\.\d+$/.test(v)).sort(cmp); - maven = stable[stable.length - 1] || ''; + candidates = all.filter(v => /^3\.\d+\.\d+$/.test(v)).sort(cmp); + maven = candidates[candidates.length - 1] || ''; } let wrapper = (process.env.WRAPPER_INPUT || '').trim(); @@ -189,6 +193,7 @@ jobs: console.log(`Target Maven: ${maven}`); console.log(`Target maven-wrapper: ${wrapper}`); fs.appendFileSync(process.env.GITHUB_OUTPUT, `maven=${maven}\n`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `maven-candidates=${JSON.stringify(candidates)}\n`); fs.appendFileSync(process.env.GITHUB_OUTPUT, `wrapper=${wrapper}\n`); JSEOF @@ -268,11 +273,13 @@ jobs: WRAPPER_LIB: ${{ github.workspace }}/ci-actions/.github/scripts/maven-wrapper-properties.js GH_CLI_LIB: ${{ github.workspace }}/ci-actions/.github/scripts/gh-cli.js GIT_PR_LIB: ${{ github.workspace }}/ci-actions/.github/scripts/git-pr-helpers.js + DEPENDABOT_IGNORE_LIB: ${{ github.workspace }}/ci-actions/.github/scripts/dependabot-ignore.js REPO: ${{ matrix.repo }} BRANCH: ${{ matrix.branch }} PROJECT: ${{ matrix.project }} TYPE: ${{ matrix.type }} MAVEN_VERSION: ${{ needs.versions.outputs.maven }} + MAVEN_CANDIDATES: ${{ needs.versions.outputs.maven-candidates }} WRAPPER_VERSION: ${{ needs.versions.outputs.wrapper }} # A scheduled run has no inputs, so `inputs.dry_run != false` would be true and the # weekly job would never actually do anything. Schedule therefore acts, while a @@ -287,8 +294,13 @@ jobs: // The generic gh-CLI and git-data-API/PR mechanics - shared with // update-antora-ui-bundle.yml, which opens bot PRs the same way. - const { ghRetry } = require(process.env.GH_CLI_LIB); + const { gh, ghRetry } = require(process.env.GH_CLI_LIB); const PR = require(process.env.GIT_PR_LIB); + const { execFileSync } = require('child_process'); + + // The ignore rules in the repository's dependabot.yml, so a branch Dependabot has been + // told to hold on an older Maven is held here too. + const { pickMavenTarget } = require(process.env.DEPENDABOT_IGNORE_LIB); // Same rules the regenerate step applies against a real checkout - see the header of that // file for why they live outside this workflow. @@ -296,7 +308,10 @@ jobs: const REPO = process.env.REPO; const BRANCH = process.env.BRANCH; - const MAVEN = process.env.MAVEN_VERSION; + // The newest stable Maven. `MAVEN`, below, is what *this branch* moves to, which is + // lower when dependabot.yml ignores the newest for it. + const NEWEST_MAVEN = process.env.MAVEN_VERSION; + let MAVEN = NEWEST_MAVEN; // In regenerate mode this step only decides *whether* work is needed and hands the // branch name to the steps below - the files are produced by the wrapper plugin // against a real checkout, which the contents API cannot do. @@ -384,6 +399,64 @@ jobs: finish('check-ok', `all ${files.length} wrapper file(s) readable by Dependabot`, extra); } + // ── Which Maven does dependabot.yml let this branch have? ─────────────────────── + // Read from the default branch (no ref), which is where Dependabot reads it from - a + // `target-branch` entry there is what pins an older branch. Any failure to read it + // means no restriction: the workflow's job is to keep wrappers current, so it fails + // open rather than stalling a branch on a flaky API call or an unparseable file. + const fetchRaw = path => { + const args = ['api', '-H', 'Accept: application/vnd.github.raw+json', + `repos/${REPO}/contents/${path}`]; + let r = gh(args); + if (!r.ok && !/404|Not Found/i.test(r.err)) r = ghRetry(args); + return r; + }; + const loadDependabotConfig = () => { + for (const path of ['.github/dependabot.yml', '.github/dependabot.yaml']) { + const r = fetchRaw(path); + if (r.ok) { + try { + return JSON.parse(execFileSync('yq', ['-o=json', '.'], + { input: r.out, encoding: 'utf8', maxBuffer: 1 << 26 })); + } catch (err) { + console.log(`Could not parse ${path}: ${(err.message || '').split('\n')[0]}`); + return null; + } + } + if (!/404|Not Found/i.test(r.err)) { + console.log(`Could not read ${path}: ${r.err}`); + return null; + } + } + return null; + }; + + let candidates = []; + try { candidates = JSON.parse(process.env.MAVEN_CANDIDATES || '[]'); } catch (e) { /* none */ } + let heldBack = null; + const dependabotConfig = candidates.length ? loadDependabotConfig() : null; + if (dependabotConfig) { + const def = ghRetry(['api', `repos/${REPO}`, '--jq', '.default_branch']); + const pick = pickMavenTarget(dependabotConfig, { + branch: BRANCH, + defaultBranch: def.ok ? def.out.trim() : '', + current: W.currentMaven((files.find(f => f.dir === '.') || files[0]).text), + candidates, + warn: msg => console.log(`dependabot.yml: ${msg}`), + }); + if (pick.skipped.length) { + const why = `${pick.skipped.map(s => s.version).join(', ')} ignored by ${pick.skipped[0].reason}`; + if (!pick.target) { + finish('ignored-by-dependabot', why, + { target: null, newest: NEWEST_MAVEN, truncated }); + } + heldBack = { versions: pick.skipped.map(s => s.version), why }; + console.log(`${REPO}@${BRANCH}: Maven ${pick.target} instead of ${NEWEST_MAVEN} - ${why}`); + } + MAVEN = pick.target; + } + fs.appendFileSync(process.env.GITHUB_OUTPUT, `maven-target=${MAVEN}\n`); + // ── Work out what each file should become ─────────────────────────────────────── // Per file, not per branch: a repository can be current at the root and years behind in a // submodule, which is exactly the state the estate was found in. @@ -401,7 +474,7 @@ jobs: const changed = planned.filter(f => f.changed); const rootFile = planned.find(f => f.dir === '.') || planned[0]; const current = rootFile.current; - const extras = { current, target: MAVEN, wrappers: planned.length, + const extras = { current, target: MAVEN, newest: NEWEST_MAVEN, heldBack, wrappers: planned.length, changedPaths: changed.map(f => f.path), truncated }; // How the work reads in the summary and the PR title/body: one file names itself, several @@ -425,6 +498,11 @@ jobs: if (planned.some(f => f.ahead)) { finish('ahead', `Maven ${current} is newer than the target ${MAVEN}`, extras); } + if (heldBack) { + finish('ignored-by-dependabot', + `Maven ${current} is the newest release allowed for this branch (latest is ${NEWEST_MAVEN}); ` + + heldBack.why, extras); + } finish('up-to-date', `Maven ${current}` + (planned.length > 1 ? ` (${planned.length} wrapper files)` : ''), extras); @@ -476,6 +554,14 @@ jobs: const headPlan = plan(onHead); const headChanged = headPlan.filter(f => f.changed); + // A PR opened before the ignore existed (for the version now ignored) is already past + // this branch's target. It is left alone - closing it is a human decision - but it must + // not read as current, nor be offered for auto-merge. + if (heldBack && headPlan.some(f => f.ahead)) { + finish('ignored-by-dependabot', + `#${pr.number} is open for a Maven the branch is held below (${heldBack.why}) - close it`, + { ...extras, prUrl: pr.html_url }); + } if (!headChanged.length) { finish('pr-open', `#${pr.number} already open for ${MAVEN}`, { ...extras, prUrl: pr.html_url, prNumber: pr.number }); @@ -551,6 +637,10 @@ jobs: 'resolve an unpublished SNAPSHOT parent POM, and it cannot parse the older ' + 'wrapper formats.', ]; + if (heldBack) { + prBody.push('', + `Maven ${heldBack.versions.join(', ')} is not used for \`${BRANCH}\`: ${heldBack.why}.`); + } if (moduleChanged.length) { prBody.push('', 'Module wrappers are included because Dependabot reads the wrapper in the directory ' + @@ -615,7 +705,8 @@ jobs: EXISTING_PR: ${{ steps.update.outputs.regenerate-pr }} CURRENT: ${{ steps.update.outputs.regenerate-current }} SAFE_NAME: ${{ steps.update.outputs.safe-name }} - MAVEN_VERSION: ${{ needs.versions.outputs.maven }} + # Per branch: lower than the newest when dependabot.yml ignores the newest for it. + MAVEN_VERSION: ${{ steps.update.outputs.maven-target || needs.versions.outputs.maven }} WRAPPER_VERSION: ${{ needs.versions.outputs.wrapper }} WRAPPER_TYPE: ${{ inputs.wrapper_type || 'bin' }} WRAPPER_LIB: ${{ github.workspace }}/ci-actions/.github/scripts/maven-wrapper-properties.js @@ -964,6 +1055,10 @@ jobs: // ── Update mode ─────────────────────────────────────────────────────────────────── md.push(`Target Maven **${process.env.MAVEN_VERSION}**, ` + `maven-wrapper **${process.env.WRAPPER_VERSION}**.`, ''); + if (of('ignored-by-dependabot').length) { + md.push('Branches marked `ignored-by-dependabot` are held below the target because ' + + 'the repository\'s `dependabot.yml` ignores that Maven version for them.', ''); + } if (dry) { md.push('> **Dry run** — no branches or pull requests were created. ' + 'Re-run with `dry_run` unchecked to open them.', ''); @@ -977,6 +1072,7 @@ jobs: `**${of('would-open').length + of('would-update-pr').length}** would change, ` + `**${of('pr-open').length}** already current, ` + `**${of('up-to-date').length}** up to date, ` + + `**${of('ignored-by-dependabot').length}** held by Dependabot ignores, ` + `**${of('error').length}** error(s).`, ''); // Module wrappers are the whole reason this workflow stopped being a one-file edit, so how