Skip to content

Update Maven Wrapper (both) #23

Update Maven Wrapper (both)

Update Maven Wrapper (both) #23

name: Update Maven Wrapper
run-name: "${{ (github.event_name != 'schedule' && inputs.check_only == true) && 'Check' || 'Update' }} Maven Wrapper${{ inputs.projects != '' && format(' [{0}]', inputs.projects) || '' }} (${{ inputs.repo_type || 'both' }})${{ (github.event_name != 'schedule' && inputs.check_only != true && inputs.regenerate == false) && ' - Properties Only' || '' }}${{ (github.event_name != 'schedule' && inputs.check_only != true && inputs.dry_run != false) && ' - Dry Run' || '' }}"
# Opens a pull request against every maintained repository/branch whose Maven wrapper is
# behind, so CI decides whether the upgrade is safe rather than a bulk push doing it blind.
#
# 1. versions - resolve the target Maven and maven-wrapper versions
# 2. setup - read config/projects.json and build one matrix entry per repo/branch
# 3. update - per repo/branch: compare, and open a PR if it is behind
# 4. summary - one report covering every combination
#
# Written after GitHub enabled its `maven-wrapper-updater` experiment, which made Dependabot
# start updating the wrapper itself and fail across the estate in two ways:
#
# * it shells out to `mvn wrapper:wrapper`, which needs to resolve the project's parent
# POM - and an unpublished SNAPSHOT parent is not resolvable, OSS or commercial
# * it cannot parse the older wrapper formats at all ("Could not determine Maven Wrapper
# version from wrapperVersion, wrapperUrl, or script files")
#
# Bringing every wrapper up to a current, consistent version removes both. Note the first
# of those returns whenever a new Maven is released and the wrappers fall behind again -
# this workflow running weekly is what keeps that from becoming an outage.
#
# EVERY wrapper in the repository is in scope, not only the root one. Dependabot looks for
# a wrapper in the directory of every pom it fetches - the root plus each <module> - and
# raises while *parsing* any it cannot read a version out of, which aborts that
# repository's entire update job: no pull requests at all, not merely no wrapper PR. A
# pristine root wrapper therefore buys nothing while a submodule still carries the
# single-line file it was given in 2018, which is the state four repositories were found
# in. Directories with a wrapper but no pom.xml are left alone: Dependabot never reads
# those, so changing them would be churn.
#
# `check_only` runs the audit on its own - it reports every wrapper file Dependabot cannot
# read and fails the run if any exist, changing nothing. That is the canary; the normal
# mode is the fix.
#
# See README-update-maven-wrapper.md for details.
on:
workflow_dispatch:
inputs:
projects:
description: 'Comma-separated list of Spring Cloud project names. When empty, every project in projects.json is processed.'
required: false
type: string
default: ''
repo_type:
description: 'Update commercial, oss, or both?'
required: false
type: choice
default: 'both'
options:
- both
- oss
- commercial
maven_version:
description: 'Target Maven version (e.g. 3.9.16). When empty, the newest stable 3.9.x on Maven Central is used.'
required: false
type: string
default: ''
wrapper_version:
description: 'Target org.apache.maven.wrapper:maven-wrapper version. When empty, the newest stable release is used.'
required: false
type: string
default: ''
regenerate:
description: 'Regenerate the whole wrapper by running maven-wrapper-plugin (updates mvnw, mvnw.cmd and the JAR too). Uncheck to only edit maven-wrapper.properties.'
required: false
type: boolean
default: true
wrapper_type:
description: 'Wrapper flavour to generate, when regenerate is checked. bin keeps the committed JAR (the shape almost every branch already has); only-script drops it.'
required: false
type: choice
default: 'bin'
options:
- bin
- only-script
- script
java_version:
description: 'JDK used to run the wrapper plugin, when regenerate is checked.'
required: false
type: string
default: '17'
auto_merge:
description: 'Merge existing wrapper PRs whose checks have all passed. Applies to this manual run only - the weekly scheduled run never merges.'
required: false
type: boolean
default: true
merge_method:
description: 'How to merge a green wrapper PR.'
required: false
type: choice
default: 'squash'
options:
- squash
- merge
- rebase
check_only:
description: 'Audit only. Reports every maven-wrapper.properties that Dependabot cannot read a wrapper version from, and fails if any exist. Changes nothing; every other input is ignored.'
required: false
type: boolean
default: false
dry_run:
description: 'Dry run, if checked nothing is created, updated or merged, but you can see what would change'
required: false
type: boolean
default: true
token:
description: 'GitHub token with contents:write and pull-requests:write on all target repos. Falls back to GH_ACTIONS_REPO_TOKEN.'
required: false
type: string
default: ''
# Mondays at ~7:00am US Eastern. As elsewhere in this repo, GitHub Actions cron is always
# UTC with no notion of DST, so this is two month-selected entries - EDT (UTC-4) and EST
# (UTC-5). Weekly rather than daily because a Maven release is a rare event and each run
# can open PRs across ~79 branches; weekly is enough to keep the wrappers from drifting
# far enough behind for Dependabot to start failing again.
schedule:
- cron: '0 11 * 3-10 1' # ~7:00am EDT Monday, March-October
- cron: '0 12 * 11,12,1,2 1' # ~7:00am EST Monday, November-February
permissions:
contents: read
jobs:
versions:
name: Resolve Target Versions
runs-on: ubuntu-latest
outputs:
maven: ${{ steps.resolve.outputs.maven }}
wrapper: ${{ steps.resolve.outputs.wrapper }}
steps:
- name: Resolve versions
id: resolve
env:
MAVEN_INPUT: ${{ inputs.maven_version }}
WRAPPER_INPUT: ${{ inputs.wrapper_version }}
run: |
node - << 'JSEOF'
const fs = require('fs');
const { execFileSync } = require('child_process');
const fetchMeta = url => {
try {
return execFileSync('curl', ['-sSL', '--max-time', '30', url], { encoding: 'utf8' });
} catch (err) {
console.log(`Could not fetch ${url}: ${err.message}`);
return '';
}
};
const versionsIn = xml =>
[...xml.matchAll(/<version>([^<]+)<\/version>/g)].map(m => m[1]);
const cmp = (a, b) => {
const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
}
return 0;
};
// Maven Central's <latest> 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.9.x rather than whatever <latest> happens to say.
let maven = (process.env.MAVEN_INPUT || '').trim();
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\.9\.\d+$/.test(v)).sort(cmp);
maven = stable[stable.length - 1] || '';
}
let wrapper = (process.env.WRAPPER_INPUT || '').trim();
if (!wrapper) {
const all = versionsIn(fetchMeta(
'https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/maven-metadata.xml'));
const stable = all.filter(v => /^\d+\.\d+\.\d+$/.test(v)).sort(cmp);
wrapper = stable[stable.length - 1] || '';
}
if (!maven || !wrapper) {
console.log(`Could not resolve target versions (maven='${maven}', wrapper='${wrapper}').`);
process.exit(1);
}
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, `wrapper=${wrapper}\n`);
JSEOF
setup:
name: Build Matrix
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.build-matrix.outputs.matrix }}
count: ${{ steps.build-matrix.outputs.count }}
excluded: ${{ steps.build-matrix.outputs.excluded }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build matrix
id: build-matrix
env:
PROJECTS_FILTER: ${{ inputs.projects }}
REPO_TYPE: ${{ inputs.repo_type || 'both' }}
run: |
node - << 'JSEOF'
const fs = require('fs');
const projects = JSON.parse(fs.readFileSync('config/projects.json', 'utf8'));
const filterRaw = (process.env.PROJECTS_FILTER || '').trim();
const filter = filterRaw
? new Set(filterRaw.split(',').map(p => p.trim()).filter(Boolean))
: new Set();
const repoType = (process.env.REPO_TYPE || 'both').trim();
const typeKeys = repoType === 'both' ? ['oss', 'commercial'] : [repoType];
// One entry per branch, not per repository - the wrapper is a file in the tree, so
// every maintained branch has its own copy and its own PR. docs-build is
// deliberately absent: it is not in projects.json, and its Dependabot config runs
// the npm ecosystem rather than maven, so its wrapper is not part of this problem.
const entries = [];
const excluded = [];
for (const [projectKey, config] of Object.entries(projects)) {
if (projectKey === 'defaults') continue;
if (filter.size > 0 && !filter.has(projectKey)) continue;
for (const typeKey of typeKeys) {
if (!config[typeKey]) continue;
const repo = typeKey === 'commercial'
? `spring-cloud/${projectKey}-commercial`
: `spring-cloud/${projectKey}`;
for (const branch of config[typeKey]?.branches?.scheduled || []) {
// `-internal` branches are left alone: they are the in-flight development
// line for the next train, so an unsolicited toolchain change there lands
// in the middle of active work rather than on a settled branch. They are
// filtered here rather than skipped inside the job so no runner is spent
// on them, and they are reported below so the omission stays visible.
if (branch.endsWith('-internal')) {
excluded.push(`${repo}@${branch}`);
continue;
}
entries.push({ project: projectKey, repo, type: typeKey, branch });
}
}
}
entries.sort((a, b) =>
a.repo.localeCompare(b.repo) || a.branch.localeCompare(b.branch));
excluded.sort();
console.log(`Repo/branch combinations to check: ${entries.length}`);
for (const e of entries) console.log(` ${e.repo}@${e.branch}`);
console.log(`Excluded (-internal): ${excluded.length}`);
for (const e of excluded) console.log(` ${e}`);
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`matrix=${JSON.stringify({ include: entries })}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `count=${entries.length}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`excluded=${JSON.stringify(excluded)}\n`);
JSEOF
update:
name: "Wrapper — ${{ matrix.repo }}@${{ matrix.branch }}"
needs: [versions, setup]
if: needs.setup.outputs.count != '0'
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 8
matrix: ${{ fromJson(needs.setup.outputs.matrix) }}
steps:
# This repository, for .github/scripts/maven-wrapper-properties.js - the rules both
# this step and the regenerate step below edit properties files by. Checked out into
# a subdirectory on purpose: a checkout at the workspace root would clean the
# wrapper-*.json result file the steps here write there.
- name: Checkout shared scripts
uses: actions/checkout@v4
with:
path: ci-actions
- name: Check and update wrapper
id: update
env:
GH_TOKEN: ${{ inputs.token || secrets.GH_ACTIONS_REPO_TOKEN }}
WRAPPER_LIB: ${{ github.workspace }}/ci-actions/.github/scripts/maven-wrapper-properties.js
REPO: ${{ matrix.repo }}
BRANCH: ${{ matrix.branch }}
PROJECT: ${{ matrix.project }}
TYPE: ${{ matrix.type }}
MAVEN_VERSION: ${{ needs.versions.outputs.maven }}
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
# manual dispatch still defaults to a dry run so a human can look first.
DRY_RUN: ${{ github.event_name == 'schedule' && 'false' || (inputs.dry_run != false) }}
REGENERATE: ${{ (github.event_name == 'schedule' || inputs.regenerate == true) }}
# Audit only. A scheduled run never audits: it fixes, which is strictly better.
CHECK_ONLY: ${{ github.event_name != 'schedule' && inputs.check_only == true }}
run: |
node - << 'JSEOF'
const fs = require('fs');
const { execFileSync } = require('child_process');
// Same rules the regenerate step applies against a real checkout - see the header of that
// file for why they live outside this workflow.
const W = require(process.env.WRAPPER_LIB);
const REPO = process.env.REPO;
const BRANCH = process.env.BRANCH;
const MAVEN = process.env.MAVEN_VERSION;
// 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.
const REGENERATE = process.env.REGENERATE === 'true';
const WRAPPER = process.env.WRAPPER_VERSION;
const DRY = process.env.DRY_RUN !== 'false';
// Audit mode: report what Dependabot would fail to parse, and write nothing at all.
const CHECK = process.env.CHECK_ONLY === 'true';
const gh = args => {
try {
return { ok: true, out: execFileSync('gh', args,
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1 << 26 }) };
} catch (err) {
return { ok: false, out: err.stdout || '',
err: (err.stderr || err.message || '').split('\n')[0].trim() };
}
};
const ghRetry = (args, attempts = 3) => {
let last;
for (let i = 1; i <= attempts; i++) {
last = gh(args);
if (last.ok) return last;
if (i < attempts) execFileSync('sleep', [String(i * 3)]);
}
return last;
};
// Write requests go through --input with a JSON file rather than repeated -f
// flags: -f cannot express the nested author/committer objects the contents API
// needs, and it would mangle the newlines in a commit message or PR body.
const ghJson = (path, method, payload) => {
fs.writeFileSync('payload.json', JSON.stringify(payload));
return ghRetry(['api', path, '--method', method, '--input', 'payload.json']);
};
const safe = `${REPO}@${BRANCH}`.replace(/[/@]/g, '-');
const finish = (status, detail, extra = {}) => {
const result = { project: process.env.PROJECT, repo: REPO, type: process.env.TYPE,
branch: BRANCH, status, detail, dryRun: DRY, checkOnly: CHECK, ...extra };
fs.writeFileSync(`wrapper-${safe}.json`, JSON.stringify(result, null, 2));
fs.appendFileSync(process.env.GITHUB_OUTPUT, `safe-name=${safe}\n`);
// Only a PR that is already open AND already at the target is a merge
// candidate. One just opened, or just moved up, has no finished CI yet.
if (status === 'pr-open' && extra.prNumber) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `merge-candidate=${extra.prNumber}\n`);
}
console.log(`${REPO}@${BRANCH}: ${status}${detail ? ` - ${detail}` : ''}`);
process.exit(0);
};
// Encoded for query strings; left raw inside a path, where GitHub accepts the
// slashes a branch name may contain.
const enc = encodeURIComponent(BRANCH);
// ── Find every wrapper Dependabot will read ─────────────────────────────────────
// Not just the root one. Dependabot's Maven file fetcher looks for a wrapper in the
// directory of every pom it fetches - the root plus each <module> - and its parser raises
// on any it cannot read a version out of, which aborts the whole repository's update job.
// A root wrapper in perfect shape therefore buys nothing while a submodule still carries
// the single-line 2018 file. One recursive tree listing finds them all in one request.
const tree = ghRetry(['api', `repos/${REPO}/git/trees/${enc}?recursive=1`]);
if (!tree.ok) {
const missing = /404|Not Found/i.test(tree.err);
finish(missing ? 'no-wrapper' : 'error',
missing ? 'branch not found' : `could not list the tree: ${tree.err}`);
}
const treeJson = JSON.parse(tree.out);
const paths = (treeJson.tree || []).map(e => e.path);
const pathSet = new Set(paths);
// GitHub caps a recursive tree listing and says so rather than lying about it. Falling
// back to the root wrapper keeps the run useful, and the flag is carried into the summary
// so "no module wrappers found" is never confused with "modules were not looked at".
const truncated = treeJson.truncated === true;
let dirs = W.wrapperDirs(paths);
if (truncated) dirs = dirs.filter(d => d === '.');
if (!dirs.length) {
finish('no-wrapper', 'no maven-wrapper.properties in any directory with a pom.xml',
{ truncated });
}
const readAt = (path, ref) => {
const r = gh(['api', `repos/${REPO}/contents/${path}?ref=${ref}`]);
if (!r.ok) return null;
const meta = JSON.parse(r.out);
return { sha: meta.sha, text: Buffer.from(meta.content, 'base64').toString('utf8') };
};
const files = [];
for (const dir of dirs) {
const path = W.propsPath(dir);
const got = readAt(path, enc);
if (!got) finish('error', `could not read ${path}`);
files.push({ dir, path, sha: got.sha, text: got.text });
}
// ── Audit mode ──────────────────────────────────────────────────────────────────
// Answers one question: would Dependabot's parser survive this branch? The scripts are
// only fetched for files that need them - i.e. those with neither wrapperVersion nor
// wrapperUrl - so a healthy branch costs no extra requests.
if (CHECK) {
const broken = [];
for (const f of files) {
if (W.dependabotWrapperVersion(f.text)) continue;
const present = W.scriptPaths(f.dir).filter(p => pathSet.has(p));
const scripts = present.map(p => readAt(p, enc)).filter(Boolean).map(s => s.text);
if (W.dependabotWrapperVersion(f.text, scripts)) continue;
broken.push({ path: f.path, reason: present.length
? `no wrapperVersion or wrapperUrl, and no version banner in ${present.join(', ')}`
: 'no wrapperVersion or wrapperUrl, and no mvnw script to read one from' });
}
const extra = { wrappers: files.length, broken, truncated };
if (broken.length) {
finish('check-broken',
`${broken.length} of ${files.length} wrapper file(s) would break Dependabot`, extra);
}
finish('check-ok', `all ${files.length} wrapper file(s) readable by Dependabot`, extra);
}
// ── 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.
const plan = list => list.map(f => {
const current = W.currentMaven(f.text);
// Never walk a file backwards. A file already past the target keeps its own Maven
// version and is still eligible for the wrapper-key repair, which is what Dependabot
// actually needs - the two concerns are independent.
const ahead = current !== null && W.cmp(current, MAVEN) > 0;
const updated = W.rewrite(f.text, { maven: ahead ? current : MAVEN, wrapper: WRAPPER });
return { ...f, current, ahead, updated, changed: updated !== f.text };
});
const planned = plan(files);
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,
changedPaths: changed.map(f => f.path), truncated };
// How the work reads in the summary and the PR title/body: one file names itself, several
// are counted, because a nine-module repository would otherwise fill the table.
const describe = list => list.length === 1
? (list[0].dir === '.' ? 'the root wrapper' : `\`${list[0].path}\``)
: `${list.length} wrapper files`;
// Only quote an arrow when the root wrapper is actually one of the files moving.
// A repository current at the root and years behind in nine submodules would
// otherwise report "Maven 3.9.16 -> 3.9.16", which reads like a no-op.
const versionPhrase = list => {
const root = list.find(f => f.dir === '.');
return root ? `Maven ${root.current} -> ${MAVEN}` : `Maven ${MAVEN}`;
};
if (!changed.length) {
if (planned.every(f => f.current === null)) {
finish('unparsed', 'could not read a Maven version from any distributionUrl', extras);
}
if (planned.some(f => f.ahead)) {
finish('ahead', `Maven ${current} is newer than the target ${MAVEN}`, extras);
}
finish('up-to-date',
`Maven ${current}` + (planned.length > 1 ? ` (${planned.length} wrapper files)` : ''),
extras);
}
// ── Is there already a PR for this branch? ──────────────────────────────────────
// The head name includes the base branch. Without it every branch in a repo would
// share one head ref, so the first matrix job would create it and the rest would
// collide - and with several maintained branches per repo, most would silently be
// skipped. Slashes are flattened so a branch like release/1.2.x stays one segment.
const slug = BRANCH.replace(/\//g, '-');
const PREFIX = 'maven-wrapper-update/';
const head = `${PREFIX}${slug}-${MAVEN}`;
const authorName = 'spring-builds';
const authorEmail = 'spring-builds@users.noreply.github.com';
// Spring Cloud repositories run a required DCO check, so the commit needs a
// Signed-off-by trailer whose address matches the commit author. Both are set
// explicitly here rather than left to the token's default identity.
const message = `Update Maven wrapper to ${MAVEN}\n\n` +
`Signed-off-by: ${authorName} <${authorEmail}>`;
// One commit for N files. The contents API writes a single file per call and therefore
// makes a separate commit for each, so a branch touching nine module wrappers would arrive
// as nine commits with the same message. The git data API builds one tree and one commit
// instead, which is also the shape the regenerate path produces.
const commitFiles = (ref, list) => {
const refSha = ghRetry(['api', `repos/${REPO}/git/ref/heads/${ref}`, '--jq', '.object.sha']);
if (!refSha.ok) return { ok: false, err: `could not read ${ref}: ${refSha.err}` };
const parent = refSha.out.trim();
const baseTree = ghRetry(['api', `repos/${REPO}/git/commits/${parent}`, '--jq', '.tree.sha']);
if (!baseTree.ok) return { ok: false, err: `could not read the tree of ${ref}: ${baseTree.err}` };
// mode 100644: maven-wrapper.properties is never executable. Everything not listed is
// inherited from base_tree, so file modes elsewhere in the repository are untouched.
const made = ghJson(`repos/${REPO}/git/trees`, 'POST', {
base_tree: baseTree.out.trim(),
tree: list.map(f => ({ path: f.path, mode: '100644', type: 'blob', content: f.updated })),
});
if (!made.ok) return { ok: false, err: `could not build a tree: ${made.err}` };
const commit = ghJson(`repos/${REPO}/git/commits`, 'POST', {
message,
tree: JSON.parse(made.out).sha,
parents: [parent],
author: { name: authorName, email: authorEmail },
committer: { name: authorName, email: authorEmail },
});
if (!commit.ok) return { ok: false, err: `could not create a commit: ${commit.err}` };
const moved = ghJson(`repos/${REPO}/git/refs/heads/${ref}`, 'PATCH',
{ sha: JSON.parse(commit.out).sha });
if (!moved.ok) return { ok: false, err: `could not move ${ref}: ${moved.err}` };
return { ok: true };
};
// Matched by prefix and base rather than by exact head, so a PR opened for an
// earlier target version is found too. Otherwise each new Maven release would
// stack another PR on top of the last one, all editing the same file.
const openPrs = ghRetry(['api',
`repos/${REPO}/pulls?state=open&base=${enc}&per_page=100`]);
if (!openPrs.ok) finish('error', `could not list pull requests: ${openPrs.err}`);
const mine = JSON.parse(openPrs.out || '[]')
.filter(pr => (pr.head?.ref || '').startsWith(`${PREFIX}${slug}-`));
if (mine.length) {
const pr = mine[0];
// Read the PR's own head branch: it may already be at the target (nothing to
// do), or behind it (a newer Maven has shipped since it was opened). The file list
// comes from the base branch, so a wrapper added to the base after the PR was opened is
// picked up too; one that has since been deleted is skipped rather than failing the job.
const headRef = encodeURIComponent(pr.head.ref);
const onHead = [];
for (const f of planned) {
const got = readAt(f.path, headRef);
if (got) onHead.push({ ...f, sha: got.sha, text: got.text });
}
const headPlan = plan(onHead);
const headChanged = headPlan.filter(f => f.changed);
if (!headChanged.length) {
finish('pr-open', `#${pr.number} already open for ${MAVEN}`,
{ ...extras, prUrl: pr.html_url, prNumber: pr.number });
}
if (DRY) {
finish('would-update-pr',
`#${pr.number} would be moved up to ${MAVEN} (${describe(headChanged)})` +
(REGENERATE ? ' (regenerated)' : ''),
{ ...extras, prUrl: pr.html_url });
}
if (REGENERATE) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `regenerate-head=${pr.head.ref}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `regenerate-pr=${pr.number}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `regenerate-current=${current}\n`);
finish('regenerating', `#${pr.number} on ${pr.head.ref}`,
{ ...extras, prUrl: pr.html_url });
}
// Commit onto the existing PR's branch so it updates in place - one wrapper PR
// per branch, always at the current target, rather than a growing stack.
const bump = commitFiles(pr.head.ref, headChanged);
if (!bump.ok) finish('error', `could not update #${pr.number}: ${bump.err}`);
finish('pr-updated', `#${pr.number} moved up to ${MAVEN} (${describe(headChanged)})`,
{ ...extras, prUrl: pr.html_url, prNumber: pr.number });
}
if (DRY) {
finish('would-open', `${versionPhrase(changed)}, ${describe(changed)}` +
(REGENERATE ? ' (regenerated)' : ''), extras);
}
// Regenerate mode stops here: the branch is created by `git push` from the real
// checkout below, not through the git refs API.
if (REGENERATE) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `regenerate-head=${head}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `regenerate-current=${current}\n`);
finish('regenerating', `${current} -> ${MAVEN} on ${head}`, extras);
}
// ── Create the branch ───────────────────────────────────────────────────────────
const baseRef = ghRetry(['api', `repos/${REPO}/git/ref/heads/${BRANCH}`, '--jq', '.object.sha']);
if (!baseRef.ok) finish('error', `could not read ${BRANCH}: ${baseRef.err}`);
const baseSha = baseRef.out.trim();
const madeRef = ghJson(`repos/${REPO}/git/refs`, 'POST',
{ ref: `refs/heads/${head}`, sha: baseSha });
if (!madeRef.ok && !/already exists/i.test(madeRef.err)) {
finish('error', `could not create ${head}: ${madeRef.err}`);
}
// An existing branch with no open PR means a previous PR was closed unmerged.
// Leaving it alone is deliberate - reopening it would re-litigate that decision.
if (!madeRef.ok) {
finish('branch-exists', `${head} exists but no PR is open - left alone`, extras);
}
// ── Commit the change ───────────────────────────────────────────────────────────
const put = commitFiles(head, changed);
if (!put.ok) finish('error', `could not commit: ${put.err}`);
// ── Open the PR ─────────────────────────────────────────────────────────────────
const rootChanged = changed.find(f => f.dir === '.');
const moduleChanged = changed.filter(f => f.dir !== '.');
const prBody = [
rootChanged
? `Updates the Maven wrapper on \`${BRANCH}\` from **${rootChanged.current}** to ` +
`**${MAVEN}**, and \`maven-wrapper\` to **${WRAPPER}**.`
: `Updates the module Maven wrappers on \`${BRANCH}\` to Maven **${MAVEN}** and ` +
`\`maven-wrapper\` **${WRAPPER}**.`,
'',
`Files changed (${changed.length}):`,
'',
...changed.map(f => `- \`${f.path}\`` +
(f.current ? ` — Maven ${f.current} → ${f.ahead ? f.current : MAVEN}` : '') +
(W.dependabotWrapperVersion(f.text) ? '' : ' — **adds the missing `wrapperVersion`**')),
'',
'Opened automatically by [update-maven-wrapper.yml](https://github.com/spring-cloud/spring-cloud-github-actions/blob/main/.github/workflows/update-maven-wrapper.yml).',
'',
'Keeping the wrapper current stops Dependabot from trying to update it itself, ' +
'which currently fails - it shells out to `mvn wrapper:wrapper`, which cannot ' +
'resolve an unpublished SNAPSHOT parent POM, and it cannot parse the older ' +
'wrapper formats.',
];
if (moduleChanged.length) {
prBody.push('',
'Module wrappers are included because Dependabot reads the wrapper in the directory ' +
'of *every* pom it fetches, and a single unreadable one aborts the whole ' +
'repository\'s update job - not merely the wrapper update.');
}
const pr = ghJson(`repos/${REPO}/pulls`, 'POST', {
title: `Update Maven wrapper to ${MAVEN}`,
head, base: BRANCH, body: prBody.join('\n'),
});
if (!pr.ok) finish('error', `could not open PR: ${pr.err}`);
const created = JSON.parse(pr.out);
finish('pr-opened', `#${created.number}: ${versionPhrase(changed)} (${describe(changed)})`,
{ ...extras, prUrl: created.html_url, prNumber: created.number });
JSEOF
# ── Regenerate mode ─────────────────────────────────────────────────────────────
# Everything below runs only when `regenerate` is set and the step above decided this
# branch needs work. Regenerating means running maven-wrapper-plugin against a real
# checkout, which rewrites mvnw, mvnw.cmd and the JAR as well as the properties -
# more than the contents API can express, hence the checkout, JDK and git push.
# This repository is already checked out into ci-actions by the first step.
- name: Checkout target branch
if: (github.event_name == 'schedule' || inputs.regenerate == true) && steps.update.outputs.regenerate-head != ''
uses: actions/checkout@v4
with:
repository: ${{ matrix.repo }}
ref: ${{ matrix.branch }}
path: target
token: ${{ inputs.token || secrets.GH_ACTIONS_REPO_TOKEN }}
fetch-depth: 0
- name: Set up JDK
if: (github.event_name == 'schedule' || inputs.regenerate == true) && steps.update.outputs.regenerate-head != ''
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ inputs.java_version || '17' }}
# .settings.xml resolves ${env.COMMERCIAL_ARTIFACTORY_USERNAME} and friends, so the
# secrets have to reach Maven as environment variables. This is the repo's own action
# for that, which also falls back to the read-only credentials when the read/write
# pair is unavailable - the case that silently produced empty credentials before.
# It writes to $GITHUB_ENV, so it must run in a step of its own, before Maven.
- name: Set commercial credentials
if: (github.event_name == 'schedule' || inputs.regenerate == true) && steps.update.outputs.regenerate-head != ''
env:
USERNAME: ${{ secrets.COMMERCIAL_ARTIFACTORY_USERNAME }}
PASSWORD: ${{ secrets.COMMERCIAL_ARTIFACTORY_PASSWORD }}
RO_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }}
RO_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }}
uses: ./ci-actions/.github/actions/set-commercial-creds-env-vars
- name: Regenerate wrapper and open PR
if: (github.event_name == 'schedule' || inputs.regenerate == true) && steps.update.outputs.regenerate-head != ''
env:
GH_TOKEN: ${{ inputs.token || secrets.GH_ACTIONS_REPO_TOKEN }}
REPO: ${{ matrix.repo }}
BRANCH: ${{ matrix.branch }}
HEAD_REF: ${{ steps.update.outputs.regenerate-head }}
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 }}
WRAPPER_VERSION: ${{ needs.versions.outputs.wrapper }}
WRAPPER_TYPE: ${{ inputs.wrapper_type || 'bin' }}
WRAPPER_LIB: ${{ github.workspace }}/ci-actions/.github/scripts/maven-wrapper-properties.js
# COMMERCIAL_ARTIFACTORY_USERNAME/PASSWORD deliberately absent: they arrive from
# $GITHUB_ENV via the step above, and a step-level env: here would override it
# with the raw secret, losing the read-only fallback. CI_DEPLOY_* is what the
# repo.spring.io server entry in .settings.xml resolves.
CI_DEPLOY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }}
CI_DEPLOY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }}
run: |
set -uo pipefail
RESULT="wrapper-${SAFE_NAME}.json"
record() {
node -e '
const fs = require("fs");
const [file, status, detail, prUrl] = process.argv.slice(1);
let r = {};
try { r = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) {}
fs.writeFileSync(file, JSON.stringify(
{ ...r, status, detail, ...(prUrl ? { prUrl } : {}) }, null, 2));
' "$RESULT" "$1" "$2" "${3:-}"
echo "${REPO}@${BRANCH}: $1 - $2"
}
cd target
# The branch's own .settings.xml is the only file used - it is what that branch's
# CI actually builds with, it is maintained per branch, and it is the one that
# names the OSS snapshot repository. There is no fallback: a branch without it
# would resolve against Maven Central alone and fail on its own SNAPSHOT parent,
# so saying so plainly beats guessing with someone else's settings.
if [ ! -f .settings.xml ]; then
cd ..; record "regenerate-failed" "no .settings.xml on this branch"; exit 0
fi
# Report which ${env.*} placeholders this branch's .settings.xml depends on and
# whether each one actually arrived. An empty credential otherwise surfaces only
# as a 401 from Artifactory, or as a resolution failure that reads like a missing
# artifact - neither of which points at the real cause. Names only; never values.
echo "Credentials referenced by .settings.xml:"
MISSING=""
for VAR in $(grep -oE '\$\{env\.[A-Za-z0-9_]+\}' .settings.xml \
| sed -E 's/^\$\{env\.([A-Za-z0-9_]+)\}$/\1/' | sort -u); do
if [ -n "${!VAR:-}" ]; then
echo " ${VAR}: set"
else
echo " ${VAR}: EMPTY"
MISSING="${MISSING} ${VAR}"
fi
done
[ -n "${MISSING}" ] && echo "Warning: empty credentials:${MISSING}"
# The runner's own mvn, deliberately not ./mvnw: using the wrapper to regenerate
# the wrapper is circular - the old wrapper would have to boot the old Maven
# before it could be replaced, which on the oldest branches means Maven 3.6.3,
# exactly the floor maven-wrapper-plugin 3.x requires. The runner's Maven is
# current and has no such constraint. Only the settings file and profile need to
# match what CI does; the Maven that writes the files does not.
#
# -Pspring matters as much as the settings file: the repositories are declared
# inside a `spring` profile, so without activating it the snapshot repositories
# are not in play at all and the parent POM cannot resolve.
#
# -N is not just a speed-up and must not be dropped. wrapper:wrapper is not an
# aggregator goal, so on a full reactor it executes once per module and writes a
# brand-new mvnw, mvnw.cmd and .mvn/wrapper into every submodule - including the
# ~180 across the estate that have never had one. Module wrappers that DO exist
# are handled by the textual edit below instead.
# Piped through tee, not redirected to a file: Maven's output has to reach the job
# log, otherwise a failure here is undiagnosable and only the one-line summary
# below survives. pipefail (set above) is what keeps mvn's exit status visible
# through the pipe.
echo "Running maven-wrapper-plugin ${WRAPPER_VERSION} for Maven ${MAVEN_VERSION}..."
if ! mvn -B -N -s .settings.xml -Pspring \
"org.apache.maven.plugins:maven-wrapper-plugin:${WRAPPER_VERSION}:wrapper" \
"-Dmaven=${MAVEN_VERSION}" "-Dtype=${WRAPPER_TYPE}" 2>&1 | tee ../mvn.log; then
cd ..
echo "::group::Maven failure detail"
grep -E "^\[(ERROR|WARNING)\]" mvn.log | tail -40 || tail -40 mvn.log
echo "::endgroup::"
# Pick the first line that actually says something. Maven leads with
# "Some problems were encountered while processing the POMs:" and trails with
# Help links, none of which identify the problem - matching those first is how
# the previous run ended up reporting a header and nothing else.
REASON=$(grep -oE "(Non-resolvable (parent|import) POM|Could not find artifact|Could not resolve dependencies|Could not transfer artifact|Failed to execute goal|Unauthorized|status code: 40[0-9])[^\"]*" mvn.log | head -1)
if [ -z "$REASON" ]; then
REASON=$(grep -E "^\[ERROR\]" mvn.log \
| grep -vE "Some problems were encountered|To see the full stack|Re-run Maven|For more information|\[Help|^\[ERROR\][[:space:]]*$" \
| head -1)
fi
[ -z "$REASON" ] && REASON="see the Maven failure detail in the job log"
REASON=$(echo "$REASON" | cut -c1-300)
record "regenerate-failed" "${REASON}"
exit 0
fi
cd ..
cd target
# Bring up the wrapper of every *module* that has one. The plugin above only
# touched the root (-N), but Dependabot reads the wrapper in the directory of
# every pom it fetches and raises on any it cannot read a version out of, which
# aborts that repository's whole update job. Same shared rules the properties-only
# path applies, so the two modes cannot produce different files.
echo "Updating module wrapper properties..."
if ! git ls-files | node -e '
const fs = require("fs");
const W = require(process.env.WRAPPER_LIB);
const paths = fs.readFileSync(0, "utf8").split("\n").filter(Boolean);
const maven = process.env.MAVEN_VERSION;
const wrapper = process.env.WRAPPER_VERSION;
let n = 0;
for (const dir of W.wrapperDirs(paths)) {
if (dir === ".") continue;
const file = W.propsPath(dir);
const before = fs.readFileSync(file, "utf8");
const current = W.currentMaven(before);
// Never walk a file backwards; the wrapper-key repair still applies.
const target = current !== null && W.cmp(current, maven) > 0 ? current : maven;
const after = W.rewrite(before, { maven: target, wrapper });
if (after === before) continue;
fs.writeFileSync(file, after);
console.log(` ${file}: Maven ${current || "?"} -> ${target}`);
n++;
}
console.log(`${n} module wrapper file(s) updated.`);
'; then
cd ..
record "regenerate-failed" "could not update the module wrapper properties"
exit 0
fi
if [ -z "$(git status --porcelain)" ]; then
cd ..
record "up-to-date" "wrapper plugin and module properties produced no changes"
exit 0
fi
echo "Changed files:"; git status --porcelain
# Spring Cloud repositories run a required DCO check, so the commit is signed off
# and the author matches that trailer.
git config user.name "spring-builds"
git config user.email "spring-builds@users.noreply.github.com"
# The branch is always cut fresh from the base branch and force-pushed, rather
# than the existing PR branch being fetched and added to. The generated files were
# produced against the base checkout, so committing them onto a divergent branch
# would mix two states; and the PR branch only ever holds our own generated
# commit, so replacing it wholesale is exactly the intent. --force rather than
# --force-with-lease because the remote branch was never fetched, which would make
# a lease check fail on stale info rather than protect anything.
git checkout -b "$HEAD_REF"
git add -A
if ! git commit -s -m "Update Maven wrapper to ${MAVEN_VERSION}"; then
cd ..; record "error" "nothing to commit after regeneration"; exit 0
fi
if ! git push --force origin "$HEAD_REF"; then
cd ..; record "error" "could not push ${HEAD_REF}"; exit 0
fi
cd ..
if [ -n "${EXISTING_PR}" ]; then
URL=$(gh pr view "${EXISTING_PR}" --repo "${REPO}" --json url --jq .url 2>/dev/null)
record "pr-updated" "#${EXISTING_PR} regenerated at ${MAVEN_VERSION}" "$URL"
exit 0
fi
BODY=$(printf '%s\n' \
"Regenerates the Maven wrapper on \`${BRANCH}\` from **${CURRENT}** to **${MAVEN_VERSION}** using maven-wrapper-plugin ${WRAPPER_VERSION} (\`${WRAPPER_TYPE}\`)." \
"" \
"Unlike the properties-only update, this also rewrites \`mvnw\`, \`mvnw.cmd\` and the wrapper JAR, so the diff is larger. **Merge only once CI is green.**" \
"" \
"Opened automatically by [update-maven-wrapper.yml](https://github.com/spring-cloud/spring-cloud-github-actions/blob/main/.github/workflows/update-maven-wrapper.yml).")
URL=$(gh pr create --repo "${REPO}" --base "${BRANCH}" --head "${HEAD_REF}" \
--title "Update Maven wrapper to ${MAVEN_VERSION}" --body "${BODY}" 2>&1) \
|| { record "error" "could not open PR: ${URL}"; exit 0; }
record "pr-opened" "${CURRENT} -> ${MAVEN_VERSION} (regenerated)" "$URL"
# Only runs when the previous step found a PR that is open and already at the target
# version - i.e. one whose CI has had a chance to finish. A PR opened or moved up in
# this same run is deliberately not a candidate: its checks have not started yet.
#
# Auto-merge is switched off for the weekly scheduled run: these are real Maven
# upgrades, so the schedule opens and refreshes PRs and a human decides when they
# land. A manual dispatch still honours the auto_merge input, so merging green PRs in
# bulk is one deliberate click away. To let the schedule merge unattended later,
# delete the `github.event_name != 'schedule'` clause below.
- name: Merge PR if all checks pass
if: >-
always()
&& github.event_name != 'schedule'
&& inputs.auto_merge != false
&& steps.update.outputs.merge-candidate != ''
env:
GH_TOKEN: ${{ inputs.token || secrets.GH_ACTIONS_REPO_TOKEN }}
REPO: ${{ matrix.repo }}
BRANCH: ${{ matrix.branch }}
PR_NUMBER: ${{ steps.update.outputs.merge-candidate }}
SAFE_NAME: ${{ steps.update.outputs.safe-name }}
MERGE_METHOD: ${{ inputs.merge_method || 'squash' }}
DRY_RUN: ${{ github.event_name == 'schedule' && 'false' || (inputs.dry_run != false) }}
run: |
node - << 'JSEOF'
const fs = require('fs');
const { execFileSync } = require('child_process');
const REPO = process.env.REPO;
const PR = process.env.PR_NUMBER;
const DRY = process.env.DRY_RUN !== 'false';
const METHOD = process.env.MERGE_METHOD || 'squash';
const FILE = `wrapper-${process.env.SAFE_NAME}.json`;
const gh = args => {
try {
return { ok: true, out: execFileSync('gh', args,
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1 << 26 }) };
} catch (err) {
return { ok: false, out: err.stdout || '',
err: (err.stderr || err.message || '').split('\n')[0].trim() };
}
};
// Merge outcome is folded back into the result the previous step wrote, so the
// summary reports one row per repo/branch rather than two disjoint views.
const record = (mergeStatus, mergeDetail) => {
let result = {};
try { result = JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (_) { /* first write */ }
fs.writeFileSync(FILE, JSON.stringify({ ...result, mergeStatus, mergeDetail }, null, 2));
console.log(`${REPO} #${PR}: ${mergeStatus}${mergeDetail ? ` - ${mergeDetail}` : ''}`);
process.exit(0);
};
const view = gh(['pr', 'view', PR, '--repo', REPO, '--json',
'mergeable,mergeStateStatus,statusCheckRollup,url']);
if (!view.ok) record('error', `could not read PR: ${view.err}`);
const pr = JSON.parse(view.out);
// Checks are read from statusCheckRollup rather than inferred from
// mergeStateStatus, which conflates failing checks with "needs review" and a
// locked branch - the same distinction dependabot-report.yml makes.
const FAILING = new Set(['FAILURE', 'TIMED_OUT', 'ERROR', 'STARTUP_FAILURE', 'CANCELLED']);
const PASSING = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
const checks = (pr.statusCheckRollup || []).map(c => {
if (c.__typename === 'StatusContext' || c.state) {
return { name: c.context || 'status', state: (c.state || '').toUpperCase() };
}
const status = (c.status || '').toUpperCase();
if (status && status !== 'COMPLETED') return { name: c.name, state: 'PENDING' };
return { name: c.name, state: (c.conclusion || 'PENDING').toUpperCase() };
});
const failing = checks.filter(c => FAILING.has(c.state));
const pending = checks.filter(c => !FAILING.has(c.state) && !PASSING.has(c.state));
if (!checks.length) record('not-merged', 'no checks have reported yet');
if (failing.length) {
record('not-merged', `failing checks: ${failing.map(c => c.name).join(', ')}`);
}
if (pending.length) {
record('not-merged', `checks still running: ${pending.map(c => c.name).join(', ')}`);
}
if (pr.mergeable === 'CONFLICTING') record('not-merged', 'conflicts with the base branch');
if (pr.mergeable !== 'MERGEABLE') record('not-merged', `mergeable is ${pr.mergeable}`);
// Green but not CLEAN means branch protection is holding it - a required review,
// or a Release Freeze on the base branch. The merge API would refuse anyway, so
// this is reported rather than attempted.
if (pr.mergeStateStatus !== 'CLEAN') {
record('not-merged', `all checks pass but GitHub reports ${pr.mergeStateStatus}`);
}
if (DRY) record('would-merge', `all ${checks.length} check(s) pass`);
const merged = gh(['pr', 'merge', PR, '--repo', REPO, `--${METHOD}`, '--delete-branch']);
if (!merged.ok) record('error', `merge failed: ${merged.err}`);
record('merged', `${METHOD}, all ${checks.length} check(s) passed`);
JSEOF
# Guarded on safe-name: if the script died before writing its result, the artifact
# name would be a bare "wrapper-", which GitHub rejects and which would turn a
# reporting gap into a job failure.
- name: Upload result
if: always() && steps.update.outputs.safe-name != ''
uses: actions/upload-artifact@v4
with:
name: wrapper-${{ steps.update.outputs.safe-name }}
path: wrapper-${{ steps.update.outputs.safe-name }}.json
if-no-files-found: ignore
summary:
name: Summary
needs: [versions, setup, update]
runs-on: ubuntu-latest
if: always()
steps:
- name: Download results
uses: actions/download-artifact@v4
with:
pattern: wrapper-*
merge-multiple: true
path: results
- name: Write summary
env:
MAVEN_VERSION: ${{ needs.versions.outputs.maven }}
WRAPPER_VERSION: ${{ needs.versions.outputs.wrapper }}
EVENT_NAME: ${{ github.event_name }}
EXCLUDED: ${{ needs.setup.outputs.excluded }}
run: |
node - << 'JSEOF'
const fs = require('fs');
let results = [];
try {
results = fs.readdirSync('results')
.filter(f => f.endsWith('.json'))
.map(f => JSON.parse(fs.readFileSync(`results/${f}`, 'utf8')))
.sort((a, b) => a.repo.localeCompare(b.repo) || a.branch.localeCompare(b.branch));
} catch (err) {
console.log('No results to summarize.');
}
const dry = results.some(r => r.dryRun);
const check = results.some(r => r.checkOnly);
const of = s => results.filter(r => r.status === s);
const md = [`## ${check ? 'Check' : 'Update'} Maven Wrapper`, ''];
// Named rather than merely counted: an omission nobody can see is one nobody can
// question, and these are branches a reader would otherwise assume were covered.
let excluded = [];
try { excluded = JSON.parse(process.env.EXCLUDED || '[]'); } catch (_) { /* absent */ }
const renderExcluded = () => {
if (!excluded.length) return;
md.push('', `### Skipped — \`-internal\` branches (${excluded.length})`, '');
md.push('Left alone deliberately: these are the in-flight development line for ' +
'the next train, so the wrapper is not changed under active work.', '');
for (const e of excluded) md.push(`- \`${e}\``);
};
// A truncated tree listing means module wrappers were never looked at on that branch, so
// a clean result there is not evidence of anything. Called out wherever it happens.
const renderTruncated = () => {
const cut = results.filter(r => r.truncated);
if (!cut.length) return;
md.push('', `> **${cut.length} branch(es) had a truncated tree listing** — only the root ` +
'wrapper was inspected there: ' + cut.map(r => `\`${r.repo}@${r.branch}\``).join(', '), '');
};
const write = failed => {
renderTruncated();
renderExcluded();
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md.join('\n') + '\n');
console.log(md.join('\n'));
// Always exits: this script runs from stdin, where a top-level `return` is a
// syntax error, so every branch below ends by calling write().
process.exit(failed ? 1 : 0);
};
// ── Audit mode ────────────────────────────────────────────────────────────────────
// A separate report, not a column bolted onto the update one: the question it answers is
// not "how far behind is this branch" but "will Dependabot survive it", and the answer has
// to be loud. The job fails when anything is broken so the workflow works as a canary -
// scheduled from elsewhere, or run by hand before wondering why Dependabot went quiet.
if (check) {
const broken = results.filter(r => r.status === 'check-broken');
const wrappers = results.reduce((n, r) => n + (r.wrappers || 0), 0);
md.push(`Checked **${wrappers}** \`maven-wrapper.properties\` file(s) across ` +
`**${results.length}** repo/branch combination(s).`, '');
md.push('A file counts as broken when Dependabot can read no wrapper version from it — ' +
'no `wrapperVersion`, no parseable `wrapperUrl`, and no version banner in `mvnw`. ' +
'Dependabot raises while *parsing* such a file, which aborts that repository’s entire ' +
'update job: no pull requests at all, not merely no wrapper PR.', '');
if (!broken.length) {
md.push('✅ Every wrapper file is readable by Dependabot.');
write(false);
}
const files = broken.reduce((n, r) => n + r.broken.length, 0);
md.push(`❌ **${files}** file(s) across **${broken.length}** repo/branch combination(s) ` +
'would break Dependabot.', '');
md.push('| Repo | Branch | File | Why |', '|---|---|---|---|');
for (const r of broken) {
for (const b of r.broken) {
md.push(`| \`${r.repo}\` | \`${r.branch}\` | \`${b.path}\` | ${b.reason} |`);
}
}
md.push('', 'Re-run this workflow with `check_only` unchecked to fix them.');
write(true);
}
// ── Update mode ───────────────────────────────────────────────────────────────────
md.push(`Target Maven **${process.env.MAVEN_VERSION}**, ` +
`maven-wrapper **${process.env.WRAPPER_VERSION}**.`, '');
if (dry) {
md.push('> **Dry run** — no branches or pull requests were created. ' +
'Re-run with `dry_run` unchecked to open them.', '');
}
const withMerge = s => results.filter(r => r.mergeStatus === s);
md.push(`${results.length} repo/branch combination(s) checked — ` +
`**${of('pr-opened').length}** PR(s) opened, ` +
`**${of('pr-updated').length}** existing PR(s) moved up, ` +
`**${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('error').length}** error(s).`, '');
// Module wrappers are the whole reason this workflow stopped being a one-file edit, so how
// many were actually touched is worth stating rather than leaving to be inferred.
const changedFiles = results.reduce((n, r) => n + (r.changedPaths || []).length, 0);
const moduleFiles = results.reduce((n, r) =>
n + (r.changedPaths || []).filter(p => p !== '.mvn/wrapper/maven-wrapper.properties').length, 0);
if (changedFiles) {
md.push(`**${changedFiles}** wrapper file(s) changed, ` +
`**${moduleFiles}** of them in module directories.`, '');
}
const merged = withMerge('merged');
const wouldMerge = withMerge('would-merge');
if (merged.length || wouldMerge.length) {
md.push(`**${merged.length}** PR(s) merged` +
(wouldMerge.length ? `, **${wouldMerge.length}** would be merged` : '') + '.', '');
}
// Said plainly, so a weekly run with open green PRs and an empty Merge column
// reads as a deliberate policy rather than as merging having quietly failed.
if (process.env.EVENT_NAME === 'schedule') {
md.push('> Auto-merge is off for scheduled runs — open PRs are left for a human. ' +
'Run this workflow manually with `auto_merge` checked to merge the green ones.', '');
}
const interesting = results.filter(r =>
!['up-to-date', 'no-wrapper'].includes(r.status));
if (!interesting.length) {
md.push('Every maintained branch is already on the target wrapper.');
} else {
md.push('| Repo | Branch | Files | Status | Merge | Detail |', '|---|---|---|---|---|---|');
for (const r of interesting) {
const detail = r.prUrl ? `[${r.detail}](${r.prUrl})` : (r.detail || '');
const merge = r.mergeStatus
? `${r.mergeStatus}${r.mergeDetail ? ` — ${r.mergeDetail}` : ''}`
: '';
const files = (r.changedPaths || []).length
? `${r.changedPaths.length}/${r.wrappers || '?'}`
: `${r.wrappers || 0}`;
md.push(`| \`${r.repo}\` | \`${r.branch}\` | ${files} | ${r.status} | ${merge} | ${detail} |`);
}
md.push('', '<sub>Files: changed / total wrapper files found in directories with a pom.xml.</sub>');
}
const quiet = of('up-to-date').length + of('no-wrapper').length;
if (quiet) {
md.push('', `<sub>${of('up-to-date').length} branch(es) already current, ` +
`${of('no-wrapper').length} with no Maven wrapper — omitted above.</sub>`);
}
write(false);
JSEOF