Update Maven Wrapper (both) #23
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |