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. Defaults to a dry run.
In August 2026 GitHub enabled a Dependabot experiment, visible as "maven-wrapper-updater": true in every Maven job definition. Dependabot began trying to update the wrapper itself,
and started failing across the estate in two distinct ways:
| Mode | Error | Cause |
|---|---|---|
| A | Non-resolvable parent POM … (absent) |
It shells out to mvn wrapper:wrapper, which forces full project-model resolution — and an unpublished -SNAPSHOT parent cannot be resolved. Affects OSS and commercial alike. |
| B | Could not determine Maven Wrapper version from wrapperVersion, wrapperUrl, or script files |
FileParser::WrapperMojo cannot parse the older wrapper formats — the oldest branches still reference the pre-Apache io.takari wrapper. |
Both come from the wrapper being old or inconsistent, so bringing every wrapper up to a current, uniform version removes both. Mode A 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.
The estate was genuinely inconsistent when this was written:
spring-cloud-stream@main Maven 3.6.3 wrapperUrl io.takari:maven-wrapper:0.5.6
spring-cloud-task@main Maven 3.9.1 wrapperUrl org.apache.maven.wrapper:3.2.0
spring-cloud-zookeeper@main Maven 3.9.0 wrapperUrl org.apache.maven.wrapper:3.1.1
spring-cloud-commons@main Maven 3.9.11 wrapperVersion 3.3.4 + distributionType=only-script
versionsresolves the target Maven andmaven-wrapperversions.setupexpandsconfig/projects.jsoninto one matrix entry per repo/branch.updatecompares every wrapper on the branch and, if any is behind or unreadable, creates a branch, commits them all in one commit, and opens a PR.summaryreports every combination in one table.
Dependabot's Maven file fetcher looks for .mvn/wrapper/maven-wrapper.properties in the
directory of every pom it fetches — the root plus each <module>. Its parser raises on
any file it cannot read a wrapper version out of, and it raises while parsing, before any
dependency work. That aborts the repository's entire Dependabot update job: no pull
requests at all, not merely no wrapper PR.
So a pristine root wrapper buys nothing while a submodule still carries the single-line file it was given in 2018:
distributionUrl=https://repo1.maven.org/maven2/org/apache/maven/apache-maven/3.6.0/apache-maven-3.6.0-bin.zipNo wrapperVersion, no wrapperUrl, and — where mvnw is old or absent — no version banner
to fall back on. Four repositories were found in exactly that state, with 45 such files
across their maintained branches, and it was the sole cause of every failing Dependabot job
in the estate.
Scope is therefore every maven-wrapper.properties in a directory that also contains a
pom.xml — precisely the set Dependabot reads. A wrapper directory with no pom.xml
beside it is left alone: Dependabot never looks at it, so touching it would be pure churn.
-internalbranches are skipped. 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 out of the matrix, so no runner is spent on them, but every one is named in the summary under Skipped — 12 of the 79 combinations at the time of writing, which is too many to drop silently. To include them, remove theendsWith('-internal')check in thesetupjob.docs-buildis not inprojects.json, and its Dependabot config runs thenpmecosystem rather thanmaven, so its wrapper is not part of this problem.
By default it regenerates the whole wrapper with maven-wrapper-plugin — see
Regenerate mode — rewriting mvnw, mvnw.cmd and the JAR along with
the properties.
Unchecking regenerate falls back to editing only
.mvn/wrapper/maven-wrapper.properties, leaving the JAR and scripts untouched.
distributionUrl→ the target Maven versionwrapperUrl→ the Apachemaven-wrappercoordinates at the target version, only if the file already has that key. This is what migrates the oldest branches offio.takariand fixes Mode B.wrapperVersion→ the target version, only if the file already has that keywrapperVersionis added when the file has neitherwrapperVersionnorwrapperUrl. This is the one key the workflow invents rather than merely refreshes, and it is the whole point: without it Dependabot's parser raises and takes the repository's update job with it. It is inserted next todistributionUrl, so a licence header stays on top.
Existing comments, licence headers, line endings and the trailing newline are preserved — it is a targeted edit, not a regeneration. Applying it to an already-current file is a byte-for-byte no-op, and applying it twice gives the same result as applying it once.
A file already ahead of the target keeps its own Maven version and is still eligible for
the wrapperVersion repair. The two concerns are independent: being on a newer Maven is no
reason to stay unreadable to Dependabot.
All of a branch's files land in one commit, built through the git data API. The contents API writes one file per call and therefore makes one commit per file, which would turn a nine-module repository into nine identical-looking commits.
The properties-only mode is the safety valve. It needs no JDK, no credentials and no checkout, produces a one-line diff, and — importantly — cannot fail on parent-POM resolution, since it never invokes Maven at all. That matters because resolution is the very thing that breaks Dependabot's own wrapper updater.
The trade-off runs the other way too: a branch whose regeneration fails reports
regenerate-failed and gets no PR at all, where properties-only would still have bumped
its version. If a branch keeps failing to regenerate, re-run it with regenerate unchecked
to at least move its Maven version forward.
On by default, including for the weekly scheduled run. The workflow checks the branch out and runs
mvn -B -N -s .settings.xml -Pspring \
org.apache.maven.plugins:maven-wrapper-plugin:<wrapper>:wrapper \
-Dmaven=<maven> -Dtype=<wrapper_type>
The runner's mvn, deliberately not ./mvnw. Using the wrapper to regenerate the
wrapper is circular: the old wrapper has to boot the old Maven before it can 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 carries no such constraint. What has to
match CI is the settings file and the profile, not the Maven that writes the files.
and commits whatever the plugin produces — mvnw, mvnw.cmd and the JAR as well as the
properties. -N keeps it to the root project, where the wrapper lives.
This is how you get off the old formats entirely rather than only correcting their version numbers.
-Dtype selects what the plugin writes. The three differ less than their names suggest —
bin and script ship a byte-identical mvnw and differ only in whether the JAR is
committed:
| Flavour | Ships | mvnw reads |
Bootstrap |
|---|---|---|---|
bin (default) |
mvnw, mvnw.cmd, + maven-wrapper.jar |
wrapperUrl |
Runs the committed JAR, which downloads Maven |
script |
scripts only | wrapperUrl |
Same script as bin, but fetches the JAR first |
only-script |
scripts only | distributionUrl |
Pure shell — no JAR; the script downloads and unpacks Maven itself |
Under bin/script the script never reads distributionUrl at all — the JAR does. Only
only-script reads it directly.
bin is the default because it is what the estate already uses: of the 79 maintained
branches, 75 are bin and only 4 are only-script. Regenerating as bin therefore keeps
each branch's existing shape and produces the smallest diff. Choosing only-script would
delete the committed JAR and swap in a different mvnw across nearly every branch at once —
a defensible modernisation, but a much larger change to land in one pass.
Either way the plugin drops .mvn/wrapper/MavenWrapperDownloader.java, a Takari-era file
present on 45 branches that no current distribution ships. That is safe: the modern script
only uses it as a fallback for downloading the JAR when neither curl nor wget exists, and
guards it with an existence check — and under bin the JAR is committed, so that path never
runs. It is safe because mvnw is replaced in the same commit; removing the file while
leaving an old Takari script in place would not be.
regenerate needs the parent POM to resolve — which is the whole reason the default mode
avoids Maven. Two things make it work, and both are easy to get wrong:
- The branch's own
.settings.xml, exactly as that branch's CI builds with it (seepr.yml's./mvnw -s .settings.xml ... -Pspring). It is maintained per branch, and it is the only file that names the OSS snapshot repository (repo.spring.io/libs-snapshot-local) — the sharedconfig/release-ci-settings.xmlin this repo is commercial-only, so using it would leave every OSS branch unable to resolve its own parent. There is deliberately no fallback: a branch without.settings.xmlwould resolve against Maven Central alone and fail on its own SNAPSHOT parent, so it reportsregenerate-failedsaying exactly that rather than guessing with someone else's settings. -Pspring. The repositories live inside aspringprofile, so without activating it the snapshot repositories are not in play at all and the parent cannot resolve, however correct the settings file is.
.settings.xml resolves its servers from ${env.*} placeholders, so the secrets only work
if they reach Maven as environment variables:
COMMERCIAL_ARTIFACTORY_USERNAME/_PASSWORDare set by this repo's ownset-commercial-creds-env-varsaction, which also falls back to the read-onlyARTIFACTORY_*pair when the read/write one is unavailable. It writes to$GITHUB_ENV, so it runs as its own step before Maven — and the regenerate step deliberately does not re-declare those two in itsenv:, since a step-level value would override$GITHUB_ENVand lose the fallback.CI_DEPLOY_USERNAME/_PASSWORDback therepo.spring.ioserver entry.
Before running Maven, the step prints every ${env.*} name the branch's .settings.xml
refers to and whether it arrived, as set or EMPTY (names only, never values). An empty
credential otherwise shows up only as a 401, or as a resolution failure that reads like a
missing artifact — neither of which points at the real cause.
The snapshot parents genuinely are published — spring-cloud-build:5.0.3-SNAPSHOT resolves
from repo.spring.io while Maven Central 404s on it, which is exactly why Dependabot's own
attempt fails: it never gets this settings file.
When resolution still fails, the branch reports regenerate-failed with the Maven error
quoted verbatim, so it is not mistaken for a workflow bug, and every other branch carries on.
Maven's full output is streamed to the job log, with the [ERROR]/[WARNING] lines
repeated under a Maven failure detail group — the extracted one-liner is a summary, never
the only record.
-N is not just a speed-up and must not be removed. 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 already exist are brought up by the
same textual edit the properties-only path uses, applied to the checkout right after the
plugin runs, so both modes produce identical files.
-N also means sibling modules are not built. That matters for spring-cloud-build, whose root POM imports one of its own modules as a BOM:
<artifactId>spring-cloud-build-dependencies</artifactId>
<version>${spring-cloud-build.version}</version>
<scope>import</scope>Import-scope BOMs are resolved during model building, so with -N that artifact must come
from a repository rather than the reactor. On OSS it does — spring-cloud-build-dependencies
snapshots are on repo.spring.io and readable anonymously. On commercial it has to come from
the Broadcom repository with credentials, and a branch whose snapshot has not been published
there will report Non-resolvable import POM however good the settings file is.
If commercial branches fail this way and OSS ones do not, that asymmetry — not the settings file — is the thing to look at.
| Input | Description | Default |
|---|---|---|
regenerate |
Run the wrapper plugin instead of editing the properties file | true |
wrapper_type |
bin (keeps the JAR), only-script (no JAR), or script — see Wrapper flavours |
bin |
java_version |
JDK used to run the plugin | 17 |
The PR branch is always cut fresh from the base branch and force-pushed. The generated
files come from a checkout of the base, so committing them onto a divergent branch would mix
two states — and since the branch only ever holds this workflow's own generated commit,
replacing it wholesale is 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.
| Default (properties only) | regenerate: true |
|
|---|---|---|
| Diff | 1–2 lines | mvnw, mvnw.cmd, JAR, properties |
| Needs a JDK, credentials, checkout | No | Yes |
| Can fail on parent resolution | No | Yes — the bug being worked around |
| Modernises the scripts | No | Yes |
Run the default for routine version bumps; run regenerate as a deliberate pass when you
want the scripts brought up to date — ideally scoped with projects to a few repos at a
time, since the diff is much larger.
https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
^^^^^^ ^^^^^^
Both the directory and the filename carry it, and both must be rewritten — changing only
the filename yields a URL that 404s and breaks every build using the wrapper. One regex
captures both so detection and rewriting can never disagree; a file that doesn't match that
shape is reported as unparsed rather than being silently treated as up to date. The host
prefix is preserved, so a repository pointing at a mirror keeps pointing at it.
With maven_version empty, the target is the newest stable 3.9.x on Maven Central, not
Central's <latest> — which is currently 4.0.0-rc-6. Dependabot itself stays on the stable
line (its logs show "Filtered out 33 pre-release versions"), so tracking 3.9.x is what
actually keeps it quiet. Set maven_version explicitly to move to a 4.x line deliberately.
Verify any version you pin by hand actually exists.
3.9.19looks plausible and does not exist — pointingdistributionUrlat it would 404 on every build.
Spring Cloud repositories run a required DCO check, so each commit carries a
Signed-off-by trailer and sets the commit author and committer explicitly to match it. If
the identity needs to change, both the trailer and the author/committer fields must move
together or DCO fails.
workflow_dispatch— on demand, with the inputs below.schedule— Mondays at ~7:00am US Eastern, as two month-selected cron entries (EDTUTC-4/ ESTUTC-5), following the DST convention used by the other scheduled workflows here. Weekly rather than daily because a Maven release is rare and each run can open PRs across ~79 branches.
| Input | Description | Required | Default |
|---|---|---|---|
projects |
Comma-separated project names. Empty processes all of them. | No | '' |
repo_type |
both, oss, or commercial |
No | both |
maven_version |
Target Maven version. Empty uses the newest stable 3.9.x. | No | '' |
wrapper_version |
Target maven-wrapper version. Empty uses the newest stable release. |
No | '' |
auto_merge |
Merge existing wrapper PRs whose checks have all passed. Manual runs only — see Auto-merge | No | true |
merge_method |
squash, merge, or rebase |
No | squash |
check_only |
Audit only — see Check mode. Changes nothing; every other input is ignored | No | false |
dry_run |
Report what would happen without creating, updating or merging anything | No | true |
token |
Needs contents:write and pull-requests:write on all targets. Falls back to GH_ACTIONS_REPO_TOKEN. |
No | '' |
A scheduled event carries no inputs at all, so every input falls back to what the
expressions decide rather than to the default: shown above. In full:
| Scheduled run | Manual dispatch | |
|---|---|---|
| Dry run? | No — it opens PRs | Yes by default; uncheck dry_run to act |
| Mode | Regenerates the full wrapper | regenerate as chosen (on by default) |
| Merges green PRs? | No | Yes, if auto_merge is left checked |
| Scope | Every project, oss and commercial |
As chosen |
| Target versions | Newest stable 3.9.x and newest maven-wrapper |
As chosen |
-internal branches |
Skipped | Skipped |
So the weekly run regenerates wrappers across the estate and leaves the PRs for a human.
Merging stays manual-only — it is the one irreversible step, and these PRs carry a real diff
(mvnw, mvnw.cmd and the JAR), so they are worth reading before they land.
Because a scheduled event carries no inputs, regenerate has to opt in explicitly for the
schedule in the same way dry_run does — github.event_name == 'schedule' || inputs.regenerate == true.
Relying on the input's default: true would not work: on a scheduled run the input is null,
not its default.
dry_run has to be forced off for the schedule — otherwise inputs.dry_run != false would
be true for a scheduled event and the weekly job would report forever without ever opening a
PR. The same asymmetry is why the run name checks github.event_name != 'schedule' before
adding its - Dry Run suffix; without that, a scheduled run would be labelled a dry run in
the Actions list while actually opening PRs.
The weekly scheduled run never merges. These are real Maven upgrades, so the schedule
opens and refreshes PRs and a human decides when they land. auto_merge applies to a manual
dispatch only — which makes merging every green wrapper PR one deliberate click, rather than
something that happens overnight. To let the schedule merge unattended later, remove the
github.event_name != 'schedule' clause from the merge step.
A scheduled run says so in its summary, so an empty Merge column next to open green PRs reads as policy rather than as merging having quietly failed.
With auto_merge enabled on a manual run (the default), a wrapper PR that is open, already
at the target version, and fully green is merged with merge_method and its head branch
deleted.
Only a PR that was already open when the run started is eligible. One opened or moved up in the same run is deliberately skipped — its checks have not started yet, so merging it would defeat the point of routing this through CI at all.
Checks are read from statusCheckRollup rather than inferred from mergeStateStatus, which
conflates failing checks with "needs review" and with a locked branch — the same distinction
dependabot-report.yml makes. A PR is merged only when every
check passes and mergeable is MERGEABLE and mergeStateStatus is CLEAN.
| Merge status | Meaning |
|---|---|
merged |
Merged and the head branch deleted |
would-merge |
Dry run — it would have been merged |
not-merged |
Reported with the reason: failing checks (named), checks still running, conflicts, no checks reported yet, or green-but-BLOCKED |
error |
The merge call itself failed |
BLOCKED with everything green usually means a required review, or a Release Freeze on the
base branch. The merge API would refuse it anyway, so it is reported rather than attempted.
Set auto_merge to false to only open and update PRs and leave merging to a human.
| Status | Meaning |
|---|---|
pr-opened |
A PR was created |
pr-updated |
An open PR was moved up to a newer target — see Rerunning |
regenerate-failed |
regenerate mode only — the wrapper plugin failed, with the Maven error quoted |
would-open / would-update-pr |
Dry run — what would happen |
pr-open |
A PR is open and already at the target; nothing done |
branch-exists |
The branch exists with no open PR — a previous PR was closed unmerged, so it is left alone rather than reopened |
up-to-date |
Already on the target |
ahead |
Newer than the target; never walked backwards |
no-wrapper |
No maven-wrapper.properties in any directory with a pom.xml |
unparsed |
No distributionUrl on the branch matched the expected shape — needs a look |
check-ok |
check_only — every wrapper file is readable by Dependabot |
check-broken |
check_only — at least one file would break Dependabot; the run fails |
error |
An API call failed; the detail is in the summary |
The summary's Files column reads changed / total wrapper files found, so 2/12 means
two of the twelve wrappers on that branch are moving.
Ticking check_only runs the audit alone. Nothing is created, updated or merged, and
regenerate, dry_run and auto_merge are all ignored.
For every wrapper file in a directory with a pom.xml, it answers one question: would
Dependabot's parser survive this file? It applies Dependabot's own rules, in its order —
wrapperVersion, then a version parsed out of wrapperUrl, then the
Apache Maven Wrapper startup script, version X banner in mvnw / mvnw.cmd. The scripts
are only fetched for files that need them, so a healthy branch costs no extra API calls.
The summary lists every broken file with the reason, and the run fails when any exist. That makes it usable as a canary — scheduled from elsewhere, or run by hand before wondering why Dependabot has gone quiet. The normal mode is the fix; this is the alarm.
The workflow is safe to run repeatedly — it derives everything from current state rather than from a queue, so rerunning never duplicates work:
| On rerun | Result |
|---|---|
| PR still open at the target, checks green | pr-open — merged only on a manual run with auto_merge on; a scheduled run leaves it |
| PR still open at the target, checks red or pending | pr-open — left for a human |
| PR merged | The branch is now current → up-to-date |
| PR open, but a newer Maven has since shipped | pr-updated — the commit lands on the existing PR's branch, moving it up in place |
| PR closed unmerged, branch still present | branch-exists — left alone, so a deliberate rejection is not re-litigated |
| PR closed unmerged, branch deleted | A fresh PR is opened. If a branch should never take the upgrade, keep the head branch or pin maven_version |
One PR per repo/branch, always at the current target. Two details make that hold:
- The head branch name includes the base branch —
maven-wrapper-update/<branch>-<maven>. Without the branch component every branch in a repo would share one head ref, so the first matrix job would create it and the rest would collide on it; with several maintained branches per repo, most would be silently skipped. - Existing PRs are matched by prefix and base, not by exact head name. A PR opened for an earlier target is therefore still found, and gets moved up rather than having a second PR stacked on top of it. Matching on the exact name would grow a new PR per Maven release, all editing the same file and all conflicting with each other once one merged.
- These PRs are real Maven upgrades, not cosmetic bumps — some branches jump from 3.6.3 or 3.8.4 to 3.9.16. The PR body says so explicitly. Merge only on green CI.
- A
Release Freezefromlock-unlock-branches.ymlrestricts pushes to the frozen release branches, so the PR branch itself is created fine, but the PR cannot be merged until the freeze lifts. - One branch failing never stops the others (
fail-fast: false,max-parallel: 8); errors are collected and listed in the summary. - This addresses the cause. The complementary mitigation is a Dependabot
ignorerule fororg.apache.maven:apache-maven, which suppresses the symptom regardless of wrapper state — seeREADME-dependabot-report.mdfor how these failures surface in the daily report.