From 0b7c23e5717f5d1d283e2d1dad083d191edb214a Mon Sep 17 00:00:00 2001 From: mega-putin Date: Wed, 30 Sep 2026 01:35:53 +0000 Subject: [PATCH] fix(release-candidate): reuse a hotfix branch at its tip If release-vX.Y.Z already moved past base_commit, propose from the tip instead of failing or resetting. Skip the version bump when the file already reads the requested version. --- .github/actions/RELEASE.md | 8 ++-- .github/actions/release-candidate/README.md | 5 +- .github/actions/release-candidate/action.yml | 49 ++++++++++++++------ .github/workflows/actions-test.yml | 28 +++++++++++ 4 files changed, 72 insertions(+), 18 deletions(-) diff --git a/.github/actions/RELEASE.md b/.github/actions/RELEASE.md index c66a8a1..0f49a60 100644 --- a/.github/actions/RELEASE.md +++ b/.github/actions/RELEASE.md @@ -185,9 +185,11 @@ candidate PR aimed at that branch). It still does not patch an older line that a higher tag has already passed. If an attempt fails after the branch was created — a broken `bump_command`, -say — re-dispatching the same version and commit reuses the branch, because -it is still sitting on exactly that commit with nothing merged into it. Any -other mismatch is refused, and the branch has to be deleted by hand. +say — re-dispatching the same version and commit reuses the branch. If +hotfix PRs have already landed on it, propose sits on the tip (it does not +reset to `base_commit`) and skips the version bump when the version file +already reads the requested version. A branch that does not contain +`base_commit` is refused. ## Installing it in a repository diff --git a/.github/actions/release-candidate/README.md b/.github/actions/release-candidate/README.md index b1c7d8e..3c9f53a 100644 --- a/.github/actions/release-candidate/README.md +++ b/.github/actions/release-candidate/README.md @@ -47,8 +47,8 @@ Family: [Release pipeline](../RELEASE.md). 1. Guard (propose) *(only if `inputs.stage == 'propose'`)* 1. Cut the release branch at the base commit (propose) *(only if `inputs.stage == 'propose' && inputs.base_commit != ''`)* 1. Bump version file *(only if `inputs.stage == 'propose'`)* -1. Run bump command *(only if `inputs.stage == 'propose' && inputs.bump_command != ''`)* -1. Draft changelog *(only if `inputs.stage == 'propose' && inputs.changelog_file != ''`)* +1. Run bump command *(only if `inputs.stage == 'propose' && inputs.bump_command != '' && steps.bump.outputs.skipped != 'true'`)* +1. Draft changelog *(only if `inputs.stage == 'propose' && inputs.changelog_file != '' && steps.bump.outputs.skipped != 'true'`)* 1. Remove git auth before opening the PR *(only if `inputs.stage == 'propose'`)* 1. Drop a stale candidate branch *(only if `inputs.stage == 'propose'`)* 1. Open candidate PR — `peter-evans/create-pull-request` *(only if `inputs.stage == 'propose'`)* @@ -66,6 +66,7 @@ Family: [Release pipeline](../RELEASE.md). - `base_commit $BASE_COMMIT is not a commit in this checkout; check the repository out with fetch-depth: 0` - `base_commit $base is not reachable from the default branch ($default); a release must ship code that landed on trunk` - `branch $branch already exists` +- `branch $branch already exists at $existing, which does not contain base_commit $base` - `after bump_command, $FILE reads $actual, expected $NEW_VERSION` - `cut runs on a merged candidate PR (pull_request closed, merged == true)` - `candidate PR #$PR_NUMBER was opened by` diff --git a/.github/actions/release-candidate/action.yml b/.github/actions/release-candidate/action.yml index ca12ba0..b771aaf 100644 --- a/.github/actions/release-candidate/action.yml +++ b/.github/actions/release-candidate/action.yml @@ -214,21 +214,32 @@ runs: fi echo "base commit: $BASE_COMMIT -> $base" fi - # The release branch must not exist yet. One exception: with an - # explicit commit, propose creates that branch before it opens the PR, - # so a re-dispatch after a failed attempt finds its own branch still - # sitting on the requested commit, with nothing merged into it. + # The release branch must not exist yet, except with an explicit + # commit: propose creates it, so a re-dispatch may find it still on + # that commit, or already ahead with hotfix PRs. Sit on the tip in + # that case (do not reset to base_commit). A branch that does not + # contain base_commit is someone else's and is refused. existing="$(git ls-remote --heads origin "refs/heads/$branch" | cut -f1)" + checkout="$base" if [[ -n "$existing" ]]; then - if [[ -n "$base" && "$existing" == "$base" ]]; then + if [[ -z "$base" ]]; then + echo "::error::branch $branch already exists"; exit 1 + fi + git fetch --quiet origin "+refs/heads/$branch:refs/remotes/origin/$branch" + if [[ "$existing" == "$base" ]]; then echo "::notice title=Reusing the release branch::$branch already exists at $base, from an earlier attempt at this candidate" + elif git merge-base --is-ancestor "$base" "$existing"; then + echo "::notice title=Reusing the release branch::$branch already has commits on top of $base; proposing from $existing" + checkout="$existing" else - echo "::error::branch $branch already exists"; exit 1 + echo "::error::branch $branch already exists at $existing, which does not contain base_commit $base" + exit 1 fi fi { echo "base=$base" echo "branch=$branch" + echo "checkout=$checkout" } >> "$GITHUB_OUTPUT" # With an explicit base commit the candidate PR cannot target the default @@ -244,15 +255,19 @@ runs: GH_TOKEN: ${{ inputs.token }} BRANCH: ${{ steps.guard.outputs.branch }} BASE: ${{ steps.guard.outputs.base }} + CHECKOUT: ${{ steps.guard.outputs.checkout }} run: | set -euo pipefail + checkout="${CHECKOUT:-$BASE}" if ! git ls-remote --exit-code --heads origin "refs/heads/$BRANCH" >/dev/null; then - gh api "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/heads/$BRANCH" -f sha="$BASE" >/dev/null - echo "created $BRANCH at $BASE" + gh api "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/heads/$BRANCH" -f sha="$checkout" >/dev/null + echo "created $BRANCH at $checkout" + else + git fetch --quiet origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH" fi - git checkout --quiet -B "$BRANCH" "$BASE" + git checkout --quiet -B "$BRANCH" "$checkout" echo "release_branch=$BRANCH" >> "$GITHUB_OUTPUT" - echo "candidate PR will target $BRANCH" + echo "candidate PR will target $BRANCH at $checkout" - name: Bump version file if: inputs.stage == 'propose' @@ -265,13 +280,21 @@ runs: TOOLS: ${{ github.action_path }}/../release-tools/release_tools.py run: | set -euo pipefail - old="$(python3 "$TOOLS" bump-file "$FILE" "$PATTERN" "$VERSION")" + old="$(python3 "$TOOLS" read-file "$FILE" "$PATTERN")" + if [[ "$old" == "$VERSION" ]]; then + echo "old=$old" >> "$GITHUB_OUTPUT" + echo "skipped=true" >> "$GITHUB_OUTPUT" + echo "$FILE already $VERSION; skipping bump" + exit 0 + fi + python3 "$TOOLS" bump-file "$FILE" "$PATTERN" "$VERSION" >/dev/null echo "old=$old" >> "$GITHUB_OUTPUT" + echo "skipped=false" >> "$GITHUB_OUTPUT" echo "$FILE: $old -> $VERSION" git diff --stat - name: Run bump command - if: inputs.stage == 'propose' && inputs.bump_command != '' + if: inputs.stage == 'propose' && inputs.bump_command != '' && steps.bump.outputs.skipped != 'true' shell: bash env: OLD_VERSION: ${{ steps.bump.outputs.old }} @@ -292,7 +315,7 @@ runs: git diff --stat - name: Draft changelog - if: inputs.stage == 'propose' && inputs.changelog_file != '' + if: inputs.stage == 'propose' && inputs.changelog_file != '' && steps.bump.outputs.skipped != 'true' id: changelog shell: bash env: diff --git a/.github/workflows/actions-test.yml b/.github/workflows/actions-test.yml index da6e89d..ec60336 100644 --- a/.github/workflows/actions-test.yml +++ b/.github/workflows/actions-test.yml @@ -682,10 +682,38 @@ jobs: base="$(cat "$RUNNER_TEMP/base")" guard 1.1.0 "$base" expect_output "base=$base" + expect_output "checkout=$base" refuse 1.1.0 "origin/main~1" "already exists" refuse 1.1.0 "" "already exists" echo "the same commit is reused; a different one, and the trunk flow, are refused" + - name: A hotfix branch that already moved is reused at its tip + run: | + set -euo pipefail + source "$SH/helpers.sh" + base="$(cat "$RUNNER_TEMP/base")" + git -C "$REPO" checkout --quiet release-v1.1.0 + git -C "$REPO" commit --quiet --allow-empty -m "fix: already on the release branch" + git -C "$REPO" push --quiet origin release-v1.1.0 + tip="$(git -C "$REPO" rev-parse HEAD)" + git -C "$REPO" checkout --quiet main + guard 1.1.0 "$base" + expect_output "base=$base" + expect_output "checkout=$tip" + : > "$OUT" + ( cd "$REPO" && env \ + GH_TOKEN=fake \ + GITHUB_REPOSITORY=megaeth-labs/fake \ + GITHUB_OUTPUT="$OUT" \ + BRANCH=release-v1.1.0 \ + BASE="$base" \ + CHECKOUT="$tip" \ + bash "$SH/cut-branch.sh" ) + expect_output "release_branch=release-v1.1.0" + [[ "$(git -C "$REPO" rev-parse HEAD)" == "$tip" ]] \ + || { echo "HEAD was reset to the base commit, dropping hotfix commits"; exit 1; } + echo "propose sits on $tip, not $base" + # The generated parts of every action README (inputs, outputs, steps, # errors) and the catalogue come from action.yml; stale docs fail here. docs: