Cut release branch #3
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: 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" |