Skip to content

Cut release branch

Cut release branch #3

Workflow file for this run

name: Cut release branch
# Step 1 of the single-branch release: cut the branch that will be
# stabilized and tagged by `release.yml`.
#
# Two modes, selected by whether `hotfixBase` is provided:
#
# - Current-line release (hotfixBase empty):
# Branches off the repository's default branch. New branch is
# `release/v<releaseVersion>`. Requires CHANGELOG has `## [Unreleased]`.
# Also opens an auto-merge PR bumping the default branch to the next
# development SNAPSHOT so feature work is never blocked.
#
# - Older-line hotfix (hotfixBase = v<A.B.C>):
# Branches off the given tag. New branch is `hotfix/v<releaseVersion>`.
# Refuses unless:
# - hotfixBase is an existing vX.Y.Z tag
# - releaseVersion is on the same minor line as hotfixBase (same
# major.minor, patch strictly greater)
# - (major, minor) is strictly older than the default branch's
# latest vX.Y.Z tag (so current-line patches go through the
# release/v* path instead).
# No next-dev bump — the older line does not carry a SNAPSHOT version
# on the default branch.
#
# Both modes set the new branch's POM versions to <releaseVersion>-SNAPSHOT
# (reactor + demo POM properties). The release workflow strips the suffix
# and commits the final version right before tagging.
#
# Trigger: maintainer dispatches from the Actions UI on the default branch.
on:
workflow_dispatch:
inputs:
releaseVersion:
description: 'New version being released (e.g. 1.3.0 or 0.5.2). No v prefix, no -SNAPSHOT suffix.'
required: true
type: string
hotfixBase:
description: 'Optional: base tag for a hotfix cut (e.g. v0.5.1). Leave empty for a current-line release off the default branch.'
required: false
type: string
default: ''
nextDevVersion:
description: 'Release mode only: next development version on the default branch (e.g. 1.3.1-SNAPSHOT). Defaults to next patch of releaseVersion. Ignored on hotfix cuts.'
required: false
type: string
concurrency:
group: cut-release
cancel-in-progress: false
# Least-privilege default; the job re-grants the write scopes it needs.
permissions:
contents: read
jobs:
cut:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Refuse if not dispatched from the default branch
run: |
ref="${{ github.ref_name }}"
default="${{ github.event.repository.default_branch }}"
if [[ "$ref" != "$default" ]]; then
echo "::error::This workflow must be dispatched from '$default' (got: $ref)."
exit 1
fi
- name: Validate inputs and compute branch name
id: validate
run: |
v="${{ inputs.releaseVersion }}"
base="${{ inputs.hotfixBase }}"
if ! [[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::releaseVersion must match X.Y.Z (got: $v)"
exit 1
fi
if [[ "$v" == *SNAPSHOT* ]]; then
echo "::error::releaseVersion must not contain SNAPSHOT"
exit 1
fi
if [[ -n "$base" ]] && ! [[ "$base" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::hotfixBase must match vX.Y.Z (got: $base)"
exit 1
fi
if [[ -z "$base" ]]; then
branch="release/v$v"
mode="release"
else
branch="hotfix/v$v"
mode="hotfix"
fi
# Compute default nextDevVersion if caller did not provide one
# (release mode only): bump patch from the release version. Keeps
# the default branch in the same X.Y series until someone explicitly
# bumps to a new minor or major by passing nextDevVersion.
next="${{ inputs.nextDevVersion }}"
if [[ "$mode" == "release" && -z "$next" ]]; then
IFS='.' read -r major minor patch <<< "$v"
next="${major}.${minor}.$((patch + 1))-SNAPSHOT"
fi
if [[ -n "$next" ]] && ! [[ "$next" =~ ^[0-9]+\.[0-9]+\.[0-9]+-SNAPSHOT$ ]]; then
echo "::error::nextDevVersion must match X.Y.Z-SNAPSHOT (got: $next)"
exit 1
fi
{
echo "release=$v"
echo "releaseSnapshot=${v}-SNAPSHOT"
echo "nextDev=$next"
echo "branch=$branch"
echo "tag=v$v"
echo "mode=$mode"
} >> "$GITHUB_OUTPUT"
- name: Check out default branch with full history and tags
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
fetch-depth: 0
- name: Refuse if branch or tag already exist
run: |
branch="${{ steps.validate.outputs.branch }}"
tag="${{ steps.validate.outputs.tag }}"
if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
echo "::error::Branch $branch already exists. Aborting."
exit 1
fi
if git ls-remote --exit-code --tags origin "$tag" >/dev/null; then
echo "::error::Tag $tag already exists. Aborting."
exit 1
fi
# Hotfix-mode guards: ensure hotfixBase exists, the new version is a
# strict patch bump on the same minor line, and the line is strictly
# older than the default branch's latest v<X.Y.Z>.
- name: Validate hotfixBase and older-line invariant
if: steps.validate.outputs.mode == 'hotfix'
run: |
default="${{ github.event.repository.default_branch }}"
base="${{ inputs.hotfixBase }}"
v="${{ steps.validate.outputs.release }}"
if ! git rev-parse --verify "refs/tags/$base" >/dev/null 2>&1; then
echo "::error::hotfixBase tag '$base' does not exist in the repository."
exit 1
fi
[[ "$base" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]
base_major="${BASH_REMATCH[1]}"
base_minor="${BASH_REMATCH[2]}"
base_patch="${BASH_REMATCH[3]}"
[[ "$v" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]
new_major="${BASH_REMATCH[1]}"
new_minor="${BASH_REMATCH[2]}"
new_patch="${BASH_REMATCH[3]}"
if (( new_major != base_major )) || (( new_minor != base_minor )); then
echo "::error::releaseVersion v$v is not on the same minor line as hotfixBase $base (expected v${base_major}.${base_minor}.*)."
exit 1
fi
if (( new_patch <= base_patch )); then
echo "::error::releaseVersion v$v must be a strict patch bump over hotfixBase $base."
exit 1
fi
if ! main_tag=$(git describe --tags --abbrev=0 "origin/$default" 2>/dev/null); then
echo "::error::Cannot determine $default's latest tag — has a release ever shipped? If not, use a release/v* cut for the first release."
exit 1
fi
echo "$default's latest tag: $main_tag"
if ! [[ "$main_tag" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "::error::$default's latest tag '$main_tag' does not match vX.Y.Z."
exit 1
fi
main_major="${BASH_REMATCH[1]}"
main_minor="${BASH_REMATCH[2]}"
if (( new_major > main_major )) || { (( new_major == main_major )) && (( new_minor >= main_minor )); }; then
echo "::error::hotfix/v$v is on line v${new_major}.${new_minor}, which is not strictly older than $default's line v${main_major}.${main_minor}."
echo "::error::Current-line patches must use a release/v* cut (leave hotfixBase empty)."
exit 1
fi
echo "Confirmed older-line hotfix: v${new_major}.${new_minor} < v${main_major}.${main_minor}"
- name: Verify CHANGELOG has an [Unreleased] section
if: steps.validate.outputs.mode == 'release'
run: |
if ! grep -q "^## \[Unreleased\]" CHANGELOG.md 2>/dev/null; then
echo "::error::CHANGELOG.md is missing '## [Unreleased]' — add one (with pending changes) before cutting a release branch."
exit 1
fi
- name: Set up JDK 21
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0
with:
java-version: '21'
distribution: 'temurin'
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Cut the new branch (release/v* off default, hotfix/v* off the tag),
# then bump POM versions to <release>-SNAPSHOT during stabilization.
# The release workflow strips the suffix and commits the final version
# right before deploy. Using SNAPSHOT here means a developer who builds
# locally gets a clearly-snapshot artifact and Maven won't cache it as
# the released version.
#
# Also bumps the authplane.sdk.version property on the demo POMs so the
# demos on the new branch reference the matching SNAPSHOT.
- name: Create branch with stabilization-snapshot version
run: |
branch="${{ steps.validate.outputs.branch }}"
if [[ "${{ steps.validate.outputs.mode }}" == "hotfix" ]]; then
base="${{ inputs.hotfixBase }}"
git checkout -b "$branch" "refs/tags/$base"
else
git checkout -b "$branch"
fi
mvn -B -ntp versions:set \
-DnewVersion=${{ steps.validate.outputs.releaseSnapshot }} \
-DprocessAllModules \
-DgenerateBackupPoms=false
for demo in mcp/demo spring/demo; do
(cd "$demo" && mvn -B -ntp versions:set-property \
-Dproperty=authplane.sdk.version \
-DnewVersion=${{ steps.validate.outputs.releaseSnapshot }} \
-DgenerateBackupPoms=false)
done
git add -A
git commit -m "release-prep: ${{ steps.validate.outputs.releaseSnapshot }}"
git push origin "$branch"
# Bump the default branch to the next dev SNAPSHOT — reactor modules
# via versions:set, then the authplane.sdk.version property in each
# demo POM separately because demos are not part of the parent reactor.
# Working-tree changes are picked up by peter-evans/create-pull-request
# in the next step — no local commit/push needed here. Skipped on the
# hotfix path: older lines do not advance the default branch.
- name: Bump default branch to next snapshot
if: steps.validate.outputs.mode == 'release'
run: |
default="${{ github.event.repository.default_branch }}"
git checkout "$default"
mvn -B -ntp versions:set \
-DnewVersion=${{ steps.validate.outputs.nextDev }} \
-DprocessAllModules \
-DgenerateBackupPoms=false
for demo in mcp/demo spring/demo; do
(cd "$demo" && mvn -B -ntp versions:set-property \
-Dproperty=authplane.sdk.version \
-DnewVersion=${{ steps.validate.outputs.nextDev }} \
-DgenerateBackupPoms=false)
done
- name: Open PR with default-branch bump
if: steps.validate.outputs.mode == 'release'
id: cpr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
branch: chore/bump-${{ github.event.repository.default_branch }}-${{ steps.validate.outputs.nextDev }}
base: ${{ github.event.repository.default_branch }}
commit-message: "chore: bump ${{ github.event.repository.default_branch }} to ${{ steps.validate.outputs.nextDev }}"
title: "chore: bump ${{ github.event.repository.default_branch }} to ${{ steps.validate.outputs.nextDev }}"
body: |
Auto-generated after cutting **${{ steps.validate.outputs.branch }}**.
The default branch now targets the next development cycle while
`${{ steps.validate.outputs.branch }}` stabilizes for release.
labels: release,automated
delete-branch: true
- name: Enable auto-merge on bump PR
if: steps.validate.outputs.mode == 'release' && steps.cpr.outputs.pull-request-number
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh pr merge ${{ steps.cpr.outputs.pull-request-number }} --auto --squash
- name: Summary
run: |
default="${{ github.event.repository.default_branch }}"
v="${{ steps.validate.outputs.release }}"
branch="${{ steps.validate.outputs.branch }}"
mode="${{ steps.validate.outputs.mode }}"
{
if [[ "$mode" == "hotfix" ]]; then
echo "### Hotfix branch cut"
echo ""
echo "- **Release version**: \`$v\`"
echo "- **Branch**: \`$branch\` (based on \`${{ inputs.hotfixBase }}\`, version \`${{ steps.validate.outputs.releaseSnapshot }}\`)"
echo ""
echo "**Next steps**:"
echo "1. On \`$branch\`: add a \`## [$v]\` section to \`CHANGELOG.md\`, land the fix (cherry-pick or direct commit)."
echo "2. When ready, run the **Release** workflow from \`$branch\` (optionally with dry-run first)."
echo "3. After publication, backport any commits that should also reach \`$default\` via the **Backport fixes** workflow using \`fromBranch=v$v\` (the tag — the branch is deleted after release)."
else
echo "### Release branch cut"
echo ""
echo "- **Release version**: \`$v\`"
echo "- **Branch**: \`$branch\` (version \`${{ steps.validate.outputs.releaseSnapshot }}\`)"
echo "- **Next dev version on \`$default\`**: \`${{ steps.validate.outputs.nextDev }}\` (auto-merge PR opened)"
echo ""
echo "**Next steps**:"
echo "1. On \`$branch\`: rename \`## [Unreleased]\` to \`## [$v]\` (or add a new dated entry), land any stabilization fixes."
echo "2. When ready, run the **Release** workflow from \`$branch\` (optionally with dry-run first)."
fi
} >> "$GITHUB_STEP_SUMMARY"