fix(release): restore PR-and-merge pattern (#824)

* fix(release): restore PR-and-merge pattern — main is protected by a ruleset

The first real run of the new release workflow (run 25349824365) failed at
the push step with:

  remote: error: GH013: Repository rule violations found for refs/heads/main.
  remote: - Changes must be made through a pull request.
  remote: ! [remote rejected] main -> main

main is protected by a ruleset that requires PRs. Earlier I checked
/repos/kdlbs/kandev/branches/main/protection (the classic branch-protection
endpoint), which reported "not protected" — but rulesets are a separate,
newer mechanism enforced at push time, not surfaced via that API. So the
"simplify to direct push" refactor in 53c2e45 was based on a false negative.

Restoring the PR-and-merge pattern that the legacy release-pr.sh used:

  1. Bump version + regenerate CHANGELOG in working tree (separate step).
  2. Create release/<TAG> branch, commit + push it.
  3. gh pr create + gh pr merge --squash --delete-branch.
  4. Pull squashed commit into local main, tag from there, push tag.

Restored pull-requests: write on the prepare job (needed for gh pr create).

Side note: --atomic in the failed run worked correctly. The tag push was
rejected together with main, so there's no orphan tag on origin from the
failed attempt.

* fix(release): retry tag push + document --auto trade-off

Two non-blocking review suggestions on PR #824:

1. Tag push hardened against transient failures. With the PR-and-merge
   pattern, the merge and the tag push are separate operations. If the
   tag push fails after the PR merges, main has the bumped commit but
   no tag — leaving the release in a half-state where downstream jobs
   never run and the next 'bump=patch' run computes the *next* patch,
   skipping the originally-intended version.

   Now: retry once on push failure. If both attempts fail, log the
   recovery command (manual 'git push origin <TAG>') and exit non-zero
   loudly so the operator knows what to do.

2. Inline comment documenting why we use 'gh pr merge' without --auto.
   The current main ruleset only requires "PR required" — no status
   checks — so immediate merge works. If status checks are added to
   the ruleset later, this code needs to switch to --auto + polling
   before the tag steps. No behavior change today.

* fix(release): delete stale release branch before pushing

If a release run fails after `git push -u origin release/<TAG>` but
before `gh pr merge` lands, the remote branch persists. Because main's
package.json hasn't been bumped, the next workflow run computes the same
NEXT version + same branch name, then fails at the push step with a
non-fast-forward rejection (same content, different SHA from the rebuild).
Recovery currently requires manually closing the stale PR and deleting
the remote branch.

Now: delete any stale remote branch with the same name as a no-op
prologue. GitHub auto-closes the corresponding stale PR when the head
branch disappears, so the subsequent `gh pr create` opens a fresh PR
cleanly.

Caught by Claude and Greptile (P1).
This commit is contained in:
José Almeida
2026-05-05 01:06:17 +01:00
committed by GitHub
parent 379230892b
commit cfcc755702
+59 -18
View File
@@ -4,7 +4,7 @@ name: release
# Trigger: GitHub Actions UI → "Run workflow" (workflow_dispatch).
#
# Flow (one click → everything below):
# 1. prepare — bump version, regen CHANGELOG, commit to main, push tag vX.Y.Z
# 1. prepare — bump version, regen CHANGELOG, open + merge release PR, push tag vX.Y.Z
# 2. build-web — build Next.js standalone bundle
# 3. build-cli — build + esbuild-bundle CLI
# 4. build-bundles — assemble bin/ + web/ + cli/ tarballs for 5 platforms
@@ -33,7 +33,7 @@ on:
- minor
- major
dry_run:
description: "Dry run (no commit, tag, npm publish, or tap update)"
description: "Dry run (no PR, tag, npm publish, or tap update)"
type: boolean
default: false
@@ -44,10 +44,11 @@ permissions:
jobs:
prepare:
name: Prepare release (bump version, commit, tag)
name: Prepare release (bump version, PR, tag)
runs-on: ubuntu-latest
permissions:
contents: write # commit + push to main, push tag
contents: write # push release branch + tag
pull-requests: write # create + merge release PR (required by main ruleset)
outputs:
version: ${{ steps.compute.outputs.version }}
tag: ${{ steps.compute.outputs.tag }}
@@ -151,36 +152,76 @@ jobs:
exit 1
fi
- name: Bump version, generate CHANGELOG, commit, tag, push
- name: Bump version + generate CHANGELOG (in working tree)
if: ${{ !inputs.dry_run }}
env:
NEXT: ${{ steps.compute.outputs.version }}
TAG: ${{ steps.compute.outputs.tag }}
run: |
set -euo pipefail
git fetch origin main
git checkout main
git reset --hard origin/main
# Bump only the main package version. optionalDependencies stay
# pinned to 0.0.0-bootstrap in committed source — publish-npm.sh
# rewrites them to the real version in-flight before publishing,
# once the matching @kdlbs/runtime-* tarballs exist on npm.
(cd apps/cli && npm version --no-git-tag-version "$NEXT")
pnpm -C apps install --no-frozen-lockfile
git-cliff --tag "$TAG" -o CHANGELOG.md
- name: Create release PR + squash-merge + tag
if: ${{ !inputs.dry_run }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NEXT: ${{ steps.compute.outputs.version }}
TAG: ${{ steps.compute.outputs.tag }}
run: |
set -euo pipefail
# main is protected by a ruleset that requires PRs, so we go through
# a release branch + auto-merged PR rather than pushing directly.
BRANCH="release/$TAG"
MSG="release: $NEXT"
git checkout -b "$BRANCH"
git add apps/cli/package.json CHANGELOG.md apps/pnpm-lock.yaml
git commit -m "release: $NEXT"
git commit -m "$MSG"
# If a previous run pushed this branch but failed before the merge
# landed, the remote branch still exists. Delete it so the push
# below is a clean -u origin "$BRANCH" rather than a non-fast-forward
# rejection. Closing the corresponding stale PR (if any) happens
# automatically when GitHub sees the head branch deleted.
git push origin --delete "$BRANCH" 2>/dev/null || true
git push -u origin "$BRANCH"
gh pr create \
--title "$MSG" \
--body "Automated release: \`$NEXT\`" \
--head "$BRANCH" \
--base main
# Immediate squash-merge. This relies on main's ruleset requiring
# only "PR required" with no status checks. If status checks are
# added to the ruleset later, this needs to switch to `--auto` +
# polling for merge completion before the tag steps below.
gh pr merge "$BRANCH" --squash --delete-branch --subject "$MSG" --body ""
# Pull the squashed commit into local main, then tag from it.
git fetch origin main
git checkout main
git reset --hard origin/main
git tag -a "$TAG" -m "release: $NEXT"
# Push commit and tag atomically — without --atomic, Git could
# accept the tag push but reject the main update on a non-fast-forward,
# leaving an orphaned tag at the pre-bump commit (and tripping the
# "tag already exists" guard on the next re-run). --atomic makes the
# whole multi-ref push succeed or fail as a unit.
git push --atomic origin main "$TAG"
# Tag push: retry once for transient network blips. If both attempts
# fail, main already has the bumped commit but no tag exists.
# Recover by manually pushing the tag from any clone pointing at the
# squashed merge commit: git push origin "$TAG"
git push origin "$TAG" || {
echo "Tag push failed; retrying in 5s..." >&2
sleep 5
git push origin "$TAG" || {
echo "ERROR: Tag push failed twice after PR merge succeeded." >&2
echo " main is at $NEXT but the tag was not created on origin." >&2
echo " Recover manually: git push origin $TAG" >&2
exit 1
}
}
- name: Dry-run summary
if: ${{ inputs.dry_run }}
@@ -195,7 +236,7 @@ jobs:
pnpm -C apps install --no-frozen-lockfile
git-cliff --tag "$TAG" -o CHANGELOG.md
echo
echo "DRY RUN: Would have committed + tagged $TAG and pushed to origin/main."
echo "DRY RUN: Would have created + merged release PR and tagged $TAG."
echo "DRY RUN: apps/cli/package.json bumped to $NEXT (in-runner only)."
echo "DRY RUN: CHANGELOG.md regenerated (in-runner only)."