An on-demand workflow that performs the chores that follow a Spring Cloud release train: verifying every project was actually tagged, seeding the next snapshot config, opening the next round of milestones, merging release branches back from the commercial repo, bumping the maintenance branches to the new snapshot versions, pushing the release tags into the OSS repos, closing milestones, publishing GitHub releases, and nudging Dependabot to drop PRs the release has superseded.
Previously all of this was done by hand, repo by repo, for up to 17 projects.
It:
- Reads the properties file for
release_versionfrom thejenkins-releaser-configbranch ofspring-cloud-release-commercial— for OSS trains too, see Where the releaser config lives — validates the inputs, and builds a matrix of{project, repo, ossRepo, commercialRepo, version, tag} - Verifies a
v<version>tag exists for every project, in the commercial repo — see Where the release branch and the tag live — a hard gate, in a single job so an incomplete release produces one consolidated failure naming every missing tag - Writes the next
<train>-snapshot.propertiesfile tojenkins-releaser-config, with every version's last segment bumped and-SNAPSHOTappended — creating it, or overwriting an existing one whose versions do not match - Opens a milestone for each new snapshot version, via the create-milestone action
- Merges
release/<version>back into the.xbranch from the commercial repo, applies the new snapshot versions with update-project-versions, pushes both commits together, pushes the release tag into the OSS repo, and comments@dependabot recreateon superseded Dependabot PRs - Closes the release milestone and publishes the GitHub release for each tag
- Writes a summary covering every phase, with everything that was skipped or blocked called out explicitly
Step 5's version bump also exists on its own, as update-versions — for when the projects have to move to a train's versions before, or independently of, a post-release run. The normal end-of-release path is still this workflow; running that one first is not required.
Always in the commercial repo, for OSS releases too. An OSS release is built in <project>-commercial: create-oss-release-branch pushes the OSS branch there as <major>.<minor>.x-internal (full history, deliberately not an orphan) and cuts release/<version> from it. The build, the staging and the tag all happen on that branch, in that repository. Nothing tags the OSS repo directly.
Two things follow, and they are why the job order looks the way it does:
- Step 2 checks the commercial repo. For an OSS run it looks there first only in the sense that a hit in the OSS repo means the tag is already where this run needs it — from a re-run, or from a version carried over from an earlier train. Otherwise the tag is found in the commercial repo and marked
commercial-pending: real, but not here yet. - Step 5 runs before step 6. The merge is what brings the tagged commit into the OSS repo; once it is reachable there, the tag ref is pushed too. Only then can a release be published against it.
Publishing is gated on the tag actually existing in the target repo. POST /releases with a tag_name that does not exist does not fail — it creates the tag, at the default branch head. On an OSS run that would silently stamp v<version> onto whatever main happened to be. So step 6 re-checks and reports no-tag rather than publishing, and a merge that could not complete therefore also holds up the release.
A project listed in the properties file may not have been released in this train — its version can be carried over from an earlier one. Rather than trying to detect that, every mutating step is a no-op when its target already exists, so the three cases resolve themselves:
| Case | Tag | Release | Milestone |
|---|---|---|---|
| Released in this train | on release/<version> in the commercial repo, pushed to the OSS repo by step 5 |
created | open → closed |
| Carried over from an earlier train | already in the primary repo | already exists → skipped | already closed → skipped |
| No commercial release since the last OSS one | OSS repo only | no tag here → skipped | absent → reported |
This is also what makes re-running the workflow safe, and what lets a projects-filtered repair run work after a partial failure.
workflow_dispatchonly. There is no schedule — this runs when a release train has shipped.
Each run is named for what it targets, so the Actions list distinguishes them at a glance:
Post Release - 2025.1.2
Post Release - 2024.0.8 (commercial)
Post Release - 2025.1.2.1 (commercial) - Dry Run
Post Release - 2025.1.2 [spring-cloud-config,spring-cloud-build] - Dry Run
(commercial) appears only when projects is empty. With a filter the commercial input is ignored — the type is derived from the project names — so printing it could contradict what the run actually did, and the -commercial suffix on the listed names already shows which line it is.
Defaults to a dry run. Set dry_run to false to actually close, create, commit and push.
The workflow exits non-zero if any project hit a merge conflict, had no usable branch to update, or could not be published because its tag never reached the repo, since those leave a project half-done. Milestones that were not found, releases that already existed, and projects with no release branch are all reported without failing the run.
| Input | Description | Required | Type |
|---|---|---|---|
release_version |
The release train version that just shipped, e.g. 2025.1.2, or 2025.1.2.1 for a commercial hotfix. Must be a plain numeric version with 3 or 4 segments. |
Yes | string |
commercial |
Was this a commercial release? Ignored when projects is supplied. |
No | boolean (default: false) |
projects |
Comma-separated project names, -commercial suffix included where applicable. Empty processes every project in the properties file. See The projects filter. |
No | string |
dry_run |
When checked, nothing is created, committed or pushed — but the summary shows what would happen. | No | boolean (default: true) |
token |
Token with write access to all target repos. Falls back to GH_ACTIONS_REPO_TOKEN. |
No | string |
| Secret | Description | Required |
|---|---|---|
GH_ACTIONS_REPO_TOKEN |
Used whenever the token input is empty. Needs write access to every target repository (contents, issues for milestones, and releases), plus write access to spring-cloud-release-commercial — required on every run, OSS included, since the releaser config lives there. |
Yes, unless token is passed |
SPRING_CLOUD_CORE_POST_RELEASE_GCHAT_WEBHOOK |
Incoming webhook URL for the Google Chat space to notify when the run finishes. If unset, the step logs that it is skipping and the run still succeeds. Never used on a dry run. | No |
Cross-repo writes rely entirely on this token — the workflow's own permissions: block is contents: read.
Scopes the run to a subset, for repairing a project a full run missed or handling one that landed late. The properties file is still the source of every version; projects only narrows which entries are acted on.
projects: spring-cloud-config,spring-cloud-build
projects: spring-cloud-config-commercial,spring-cloud-gateway-commercial
- Commerciality is derived from the names, not from the
commercialinput —spring-cloud-config-commercialis commercial,spring-cloud-buildis OSS. Whenprojectsis supplied,commercialis ignored and the run logs that it was. - The list must be all commercial or all OSS. A mixed list fails the workflow before any repository is touched:
This is a correctness requirement rather than a convenience check: the derived type selects which properties file is read, and one
ERROR: projects mixes commercial and OSS projects. commercial: spring-cloud-config-commercial oss: spring-cloud-build, spring-cloud-gateway A run targets one release train, so every project must be the same type.release_versioncannot name both the OSS train (2025.1.2) and the commercial one (2025.1.2.1). A mixed list would silently read the wrong file and resolve every project to a wrong version. - The properties-file key is the name with
-commercialstripped — keys are always bare (releaser.fixed-versions[spring-cloud-config]) even in the commercial file. - A listed project that is not in the properties file fails the run, naming it and listing the known projects, rather than silently doing nothing.
- Empty entries are dropped, so a trailing comma (
spring-cloud-config,) is accepted — matching how ci-status-report and rollout-deploy-docs parse the same input.
The filter does not apply to step 3. The snapshot properties file is train-wide and stays complete: update-project-versions needs the whole versions map to update each project's dependency versions, so a file containing only the filtered projects would produce wrong POMs. Step 3 always writes every entry; only the repo-facing steps are filtered.
Always spring-cloud/spring-cloud-release-commercial, on the jenkins-releaser-config branch — including for OSS trains. That repository holds the releaser config for every train, so the location is deliberately not derived from the commercial input.
commercial still decides everything else: which project repositories are acted on (<project> vs <project>-commercial), whether release notes are sanitized, the OSS tag fallback, and whether a missing .x branch falls back to main.
This also applies to the version bump in step 5c. update-project-versions picks its config source from its own commercial input, and that input does nothing else in the action — so the workflow passes a hardcoded true. Passing the run's actual flavour would send an OSS run to spring-cloud-release, where the file no longer is.
spring-cloud/spring-cloud-release may still contain older copies of the same filenames, and they can disagree. When this changed, 2025_1_3.properties existed in both, with four projects differing:
| Project | spring-cloud-release |
spring-cloud-release-commercial |
|---|---|---|
spring-cloud-netflix |
5.0.3 | 5.0.2 |
spring-cloud-task |
5.0.3 | 5.0.2 |
spring-cloud-vault |
5.0.3 | 5.0.2 |
spring-cloud-zookeeper |
5.0.3 | 5.0.2 |
Only the commercial repository's values are correct — v5.0.2 is tagged in each of those four OSS repos and v5.0.3 does not exist, so a run against the old location would have failed the tag gate on all four.
The correct contents are always computed from the release properties file, and the target is written whether or not it already exists. An existing snapshot file may have been seeded by hand, or by an earlier run against a different release, so trusting it would leave every downstream project bumped to the wrong versions.
| Situation | Status | What happens |
|---|---|---|
| Target absent | created |
Written as a new file |
| Target exists, versions differ | updated |
Overwritten with the computed versions |
| Target exists, already byte-identical | unchanged |
Nothing committed — avoids an empty commit |
An update sends the existing blob sha with the PUT. That is what makes the Contents API replace rather than create, and it also makes the write fail rather than clobber if someone else changed the file between the read and the write.
Only the file for this run's next version is touched. Other -snapshot.properties files on the branch — older trains, other lines — are left alone.
The same branch also holds files like 2025_1_3-internal-snapshot.properties, whose versions carry an -INTERNAL-SNAPSHOT suffix. This workflow neither reads nor writes those. They belong to create-oss-release-branch, which uses them to rewrite a new release branch's versions from -SNAPSHOT to -INTERNAL-SNAPSHOT, and looks them up by the train version being started (2026.1.0 → 2026_1_0-internal-snapshot.properties) rather than by a patch-bumped next version.
So the two conventions coexist without overlapping:
| File | Written by | Purpose |
|---|---|---|
<next>-snapshot.properties |
this workflow, step 3 | next patch versions, -SNAPSHOT |
<train>-internal-snapshot.properties |
maintained separately | -INTERNAL-SNAPSHOT stamps for a new OSS release branch |
Note this differs from ci-status-report and rollout-deploy-docs, which pair bare project names with a separate repo_type input. Here the suffix carries the type, so there is no repo_type.
A 4-segment release_version (e.g. 2025.1.2.1) is treated as a commercial hotfix and runs only steps 1, 2 and 6 — verify tags, close the milestone, create the release. Steps 3, 4 and 5 are skipped: there is no next snapshot train for a hotfix, no new milestone, no version bump, no Dependabot pass, and no merge-back, because a hotfix has no branch to merge back into (the release/<version> branch is itself the hotfix line).
Because the merge back is skipped for a hotfix, step 6 cannot simply needs: it — a skipped dependency would skip the release step too, and closing the milestone and publishing the release is the whole of a hotfix run. It is gated on !cancelled() instead. A hotfix is always commercial, so its tag is already in the repo being published to and needs no push.
Commercial trains come in both shapes — 2024_0_8.properties is a 3-part commercial release whose projects carry 3-part versions and which runs the full sequence, while 2025_1_2_1.properties is the 4-part hotfix. So the mode is decided by the version's shape, not by the commercial input.
Release bodies come from GitHub's own generator (POST /repos/{repo}/releases/generate-notes), which is PR-based and honors a project's .github/release.yml, then:
- OSS projects: used verbatim, reproducing what the existing releases look like —
## :heart: Contributors,## What's Changedwith one line per merged PR,## New Contributors, and a**Full Changelog**compare link. - Commercial projects: sanitized. The contributor sections, the
**Full Changelog**line, and theby @<user> in <url>suffix on each bullet are stripped, because those PR links point into a private repository and are useless to most readers. This reproduces the existing commercial release bodies. spring-cloud-release: gains a## What's Includedsection listing every project version in the BOM with a link to that project's release, built from the same properties file.
Release titles are deliberately not normalized: OSS projects use the bare version (5.0.4) while spring-cloud-release and every commercial repo use a v prefix (v2025.1.2, v4.2.8), matching what already exists in each.
generate-notes is PR-based: it lists merged pull requests between the previous tag and this one. When it finds none, it returns nothing but a **Full Changelog** link — which sanitization strips, leaving an empty body. The release body then becomes a placeholder:
Released from tag v3.2.17.
The changes for these releases have to be written up by hand. There is nothing for the workflow to recover: the information simply is not in the PR history.
Two distinct situations produce it, and both occur in practice:
- No merged PRs in the range. The changes landed as direct commits rather than through pull requests.
generate-notesfinds the predecessor perfectly well — forspring-cloud-function-commercialv3.2.17 it returnedcompare/v3.2.16...v3.2.17— but there are no PRs in it to list. This is the common case, and it is unrelated to how old the line is. - No predecessor tag at all. Commercial branches are created as orphan branches with no history shared between lines, so the first tag on a new line has nothing to diff against —
compare v4.2.8...v5.0.4.1returns404 No common ancestor. Heregenerate-notesemitscommits/<tag>rather than acompare/link.
The check runs after What's Included is prepended, so the release train's own release keeps its version list rather than being replaced by the placeholder.
Neither situation is something a later run fixes, and a line having many tags is no guarantee against it — spring-cloud-gateway-commercial v3.1.14 got a placeholder despite v3.1.11 through v3.1.13 existing. Where a project does merge its work through PRs, notes generate normally: v4.2.9, 42 commits after v4.2.8 on the same line, produced a full What's Changed list of 8 entries.
Two details, both there to avoid shipping dead links in a release body:
- It omits
spring-cloud-releaseitself. The hand-written body for v2025.1.2 has aSpring Cloud Starter Buildline pointing atspring-cloud/spring-cloud-starter-build, but that repository does not exist — the link 404s. The other 16 lines match the hand-written body exactly. - On a commercial train, each link follows the tag that actually exists. Some entries in a commercial properties file carry the plain OSS version because that project had no commercial release (
spring-cloud-bus=5.0.2in2025_1_2_1.properties), andspring-cloud-bus-commercialhas nov5.0.2tag. Those entries link to the OSS repository; entries with a real commercial version link to the-commercialone.
For each project, the branch to merge into and bump is derived by dropping the last segment of the new snapshot version and appending .x — 5.0.3-SNAPSHOT → 5.0.x, and for a 3-part commercial train 4.2.8-SNAPSHOT → 4.2.x.
If that branch does not exist:
- OSS → falls back to
main, but only after confirming the rootpom.xmlversion (or its<parent>version) is on the samemajor.minorline. Otherwise the project is skipped and reported, rather than bumping an unrelated line to these versions. - Commercial → fails and is reported. Commercial repositories have no
mainbranch at all —spring-cloud-config-commercial's default branch is4.3.x— so there is no sane fallback.
The commit each tag points at lives on a release/<version> branch, which has to come back to the .x branch before anything else touches it.
- No
release/<version>branch → nothing to merge; the run continues to the version bump. Expected for carried-over versions, OSS-fallback entries, and branches already merged and deleted. - Already merged → reported as such, no commit. The merge check is
git merge-base --is-ancestor, so this is naturally idempotent. - Conflict → the merge is aborted, and the version bump, the push and the Dependabot pass are all skipped for that project. Other projects continue. The summary flags it under Blocked on a manual merge and the run exits non-zero. Resolve it by hand, then re-run with
projectsset to just the affected projects.
The merge commit and the version-bump commit go up in a single push: one CI run per project, and if anything fails in between, nothing is pushed and the branch is left untouched so the whole project can simply be re-run. The release/<version> branch is left in place, not deleted.
When a release branch is created, spring-release-train-project-ready deletes ci.yml, ci.yaml, pr.yml, pr.yaml (and ci-release.yml, release-ci-settings.xml) from it, so the release branch does not run normal CI. Merging that branch back would carry the deletion onto the maintenance branch and leave it with no CI at all.
This is not hypothetical — spring-cloud-config-commercial's 4.3.x has ci.yml and pr.yml today, and release/4.3.5 has neither.
So, around the merge:
- Before merging, the
.xbranch's copies ofci.yml,ci.yaml,pr.ymlandpr.yamlare saved outside the clone. A file that is not on the branch is logged and skipped. - After a clean merge, any of those files the merge removed is restored and committed as a separate commit —
Restoring ci.yml pr.yml removed by the release/4.3.5 merge— so the log shows plainly that they came back and why. - A file the release branch modified rather than deleted is left alone; that is a real change worth keeping.
The summary's merge-back table has a CI files restored column, and notes how many projects needed a restore.
Only ci and pr are handled. ci-release.yml and release-ci-settings.xml are also deleted from release branches but are not restored — they are release-branch scaffolding rather than something the maintenance branch needs.
If the merge conflicts on one of these files — because the release branch deleted it and the .x branch has since modified it, which git cannot resolve on its own — the project is blocked like any other conflict. The summary lists the conflicting paths and calls this case out specifically, since the resolution is almost always "keep the .x branch's version".
The version-bump commit message deliberately omits [skip actions], unlike most workflows in this repo — the point of pushing new snapshot versions is to start CI on them.
There is no public API to trigger a Dependabot run, so the workflow uses the supported comment command. For each project it lists open PRs authored by app/dependabot against the branch it just pushed, keeps the ones whose title mentions both a project name and a version from this release, and comments @dependabot recreate. Dependabot re-evaluates and closes any PR whose bump the push already satisfied. Matching on both name and version means unrelated bumps are left alone.
This only runs for projects that were actually pushed to.
The job summary has one table per phase. Because most steps are no-ops when their target already exists, the icons distinguish did it from it was already done — otherwise a run that changed nothing would look identical to one that did all the work.
- ✅ done — closed, created, merged, pushed
- ➖ already done — already closed, already exists, already merged, nothing to push
- 🔎 dry run — would close, would create, would push
- ⏭️ deliberately skipped — e.g. satisfied by the OSS tag
- ❔ nothing found — no milestone to close, no version for this project
- ❌ needs attention — merge conflict, no usable branch
Followed by explicit sections for anything that needs a human: Blocked on a manual merge, No branch to update, No milestone found to close, Satisfied by the OSS tag, and No release branch to merge.
When SPRING_CLOUD_CORE_POST_RELEASE_GCHAT_WEBHOOK is set and the run is not a dry run, a summary is posted to Google Chat, ending with a request to write up anything the generated notes cannot capture:
⚠️ *Post Release — 2025.1.2* (OSS)
Milestones closed: 7 closed, 10 none found
Releases: 1 already existed, 6 published, 10 skipped (OSS tag)
Next snapshot: 2025.1.3 (2025_1_3-snapshot.properties created)
New milestones: 5 already existed, 12 created
Merge back: 6 already merged, 1 BLOCKED by a conflict, 9 merged, 1 not reached
Version bumps: 6 already up to date, 9 pushed, 2 not reached
CI/PR workflow files kept from the maintenance branch: 8 project(s)
Dependabot: 18 PR(s) asked to recreate
*2 project(s) need attention:*
• spring-cloud/spring-cloud-config-commercial — blocked on a manual merge of release/5.0.4 into 5.0.x
*Please update your project's release notes* with anything that needs calling
out — CVEs, breaking changes, deprecations, or other notable fixes. ...
<https://…/actions/runs/123|View the full report>
Notes on this:
- Skipped entirely for a dry run. There is nothing to announce, and asking people to write up release notes would be misleading when no release was published.
- Still posted when the run is red — the message is built before the summary step exits non-zero, because a blocked project is exactly when people need to see it.
- Chat uses its own lightweight formatting (
*bold*,<url|text>, no tables) rather than GitHub markdown, so the message is built separately from the job summary. Same approach as ci-status-report. - Per-phase counts are produced by grouping on whatever status values are present, not by listing the expected ones, so a status nobody enumerated appears under its raw name rather than being silently dropped. The counts always add up to the number of projects.
spring-bootis in every properties file but is not a Spring Cloud repository, so it is excluded from tag checks, milestones, releases and branch updates. It is bumped in the snapshot properties file, matching the existing files.- Pre-release qualifiers (
-M1,-RC1) are rejected rather than half-handled — bumping the patch of2025.1.0-RC1produces a version nobody wants, and post-release chores are not run for milestones or release candidates. update-project-versionsis called withrelease-train-versionrather than an explicit versions map, because only that path appliesproject-version-substitutions(which mapsspring-cloud-dependencies-parent→spring-cloud-build,verifierVersion→spring-cloud-contract, and so on). That path resolves overraw.githubusercontent.com, which is CDN-cached, so after committing the snapshot file the workflow waits for the raw URL to serve it before any project is updated. If the CDN never catches up, version updates are skipped rather than applied from a stale file, and the run can simply be repeated.- Tag existence is checked with
git/matching-refsand an exact comparison, not a plaingit/refs/tags/<tag>lookup, which would also prefix-matchv5.0.20when asked forv5.0.2. max-parallel: 8keeps the fan-out from saturating the runner pool;fail-fast: falseso one bad project does not abandon the rest.- Step 4 (new milestones) and step 5 (merge back, bump and tag) run in parallel — neither depends on the other. Step 6 waits on step 5, because that is where the tag arrives.
- release-train-ready.yml — marks a single project ready in a Spring release train, reading the same
jenkins-releaser-configproperties files - create-hotfix-release-branch.yml — creates the commercial
release/<version>branches this workflow later merges back