From cfcc755702f64ca6571f2325c9130e93ac989c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Tue, 5 May 2026 01:06:17 +0100 Subject: [PATCH] fix(release): restore PR-and-merge pattern (#824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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/ 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 ') 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/` 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). --- .github/workflows/release.yml | 77 +++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 855e754bdc..0c79b9e59d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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)."