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:
@@ -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)."
|
||||
|
||||
|
||||
Reference in New Issue
Block a user