The finalize job created the release/hotfix -> main merge-back PR but never merged it, so every release and hotfix needed a manual merge click on main. The opposite direction (main -> next) is already admin-merged by auto-backmerge.yml; this direction was the asymmetric manual step where the recurring divergence got hand-reconciled. Adds a finalize step (after "Verify publish", so main only absorbs a confirmed-published release) that finds the open merge-back PR, polls until GitHub settles its mergeability, and admin-merges it with a merge commit ONLY when MERGEABLE. A CONFLICTING PR is left open for manual resolution rather than force-merged. Non-fatal (continue-on-error): the tag + npm publish already happened, so a merge-back that can't complete (org PR policy, token) must not fail the release. With the main-is-ancestor-of-next invariant restored (#2504), this merge is clean every release -- verified: a simulated 1.8.1 hotfix merges to main producing [1.8.1]->[1.8.0]->[1.7.0]->[1.6.1] and version 1.8.1 with no conflict, and main's hardened auto-backmerge.yml survives the merge. Guarded by three assertions in release-backmerge-invariants.test.cjs (step present + admin-merge, gates on MERGEABLE, continue-on-error); verified they fail when --admin or the MERGEABLE guard or the continue-on-error is removed. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
43
.github/workflows/release.yml
vendored
43
.github/workflows/release.yml
vendored
@@ -669,6 +669,49 @@ jobs:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: node scripts/verify-npm-publish.cjs --package @opengsd/gsd-core --version "$VERSION" --dist-tag latest
|
||||
|
||||
# Auto-merge the release/hotfix → main PR when it is cleanly mergeable, so
|
||||
# the release's own branch lands on `main` without a manual merge click
|
||||
# every release (#2515). Symmetric with auto-backmerge.yml's admin-merge of
|
||||
# the main → next PR; the `main`-is-ancestor-of-`next` invariant restored
|
||||
# by #2504 makes this merge clean every release. Runs only after the tag +
|
||||
# npm publish are verified, so `main` never absorbs an unpublished release.
|
||||
# A non-clean PR (a genuine divergence) is left OPEN for manual resolution
|
||||
# rather than force-merged. Non-fatal: a published release must stand even
|
||||
# if the merge-back can't auto-complete (org PR-policy, token, etc.).
|
||||
- name: Auto-merge the release → main PR when clean
|
||||
if: ${{ !inputs.dry_run }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GSD_BOT_PR_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
BRANCH: ${{ needs.validate-version.outputs.branch }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
PR=$(gh pr list --base main --head "$BRANCH" --state open --json number --jq '.[0].number // empty' 2>/dev/null || echo "")
|
||||
if [ -z "$PR" ]; then
|
||||
echo "No open release→main PR for $BRANCH (creation may be blocked by org policy); nothing to auto-merge."
|
||||
exit 0
|
||||
fi
|
||||
# GitHub computes mergeability asynchronously; poll until it settles to
|
||||
# a terminal state before deciding.
|
||||
STATE="UNKNOWN"
|
||||
for _ in 1 2 3 4 5 6 7 8; do
|
||||
STATE=$(gh pr view "$PR" --json mergeable --jq '.mergeable' 2>/dev/null || echo "UNKNOWN")
|
||||
if [ "$STATE" = "MERGEABLE" ] || [ "$STATE" = "CONFLICTING" ]; then break; fi
|
||||
sleep 5
|
||||
done
|
||||
if [ "$STATE" = "MERGEABLE" ]; then
|
||||
# Merge commit (not squash) keeps the release branch + its version tag
|
||||
# in main's ancestry. --admin bypasses the review gate for this
|
||||
# already-tested, already-published release branch.
|
||||
if gh pr merge "$PR" --admin --merge; then
|
||||
echo "Auto-merged release→main PR #$PR."
|
||||
else
|
||||
echo "::warning::PR #$PR is mergeable but admin-merge failed (token/policy?). Merge it manually."
|
||||
fi
|
||||
else
|
||||
echo "::warning::Release→main PR #$PR is not cleanly mergeable (state: $STATE). Left open for manual resolution."
|
||||
fi
|
||||
|
||||
# Regression #2423: keep `next` at the last published release for final
|
||||
# releases, not just rc/hotfix. Without this, `next` drifts to whatever
|
||||
# rc.N the release branch forked from, and every npm script banner on
|
||||
|
||||
@@ -119,4 +119,59 @@ describe('release backmerge invariants (#2504) — release.yml finalize', () =>
|
||||
'cancels finalize mid-test before tag/publish as the unit suite grows. See #2280/#2281.'
|
||||
);
|
||||
});
|
||||
|
||||
// #2515: the release/hotfix → main merge-back must complete automatically when
|
||||
// clean, not sit as a manual merge every release. This locks in that step and
|
||||
// its guardrails: it merges only a MERGEABLE PR (never force-merges a
|
||||
// conflict), and is non-fatal (a published release stands even if the
|
||||
// merge-back can't auto-complete).
|
||||
const finalizeSteps = (wf.jobs && wf.jobs.finalize && wf.jobs.finalize.steps) || [];
|
||||
const automerge = finalizeSteps.find(
|
||||
(s) => typeof s.name === 'string' && s.name.includes('Auto-merge the release')
|
||||
);
|
||||
|
||||
test('finalize auto-merges the release/hotfix → main PR', () => {
|
||||
assert.ok(automerge, "expected a finalize step named like 'Auto-merge the release → main PR'");
|
||||
assert.match(
|
||||
automerge.run || '',
|
||||
/pr merge\b[^\n]*--admin/,
|
||||
'the auto-merge step must admin-merge the merge-back PR (symmetric with auto-backmerge main→next). See #2515.'
|
||||
);
|
||||
});
|
||||
|
||||
test('the auto-merge only fires on a cleanly MERGEABLE PR (never force-merges a conflict)', () => {
|
||||
assert.ok(automerge, "expected the 'Auto-merge the release → main PR' step");
|
||||
assert.match(
|
||||
automerge.run || '',
|
||||
/MERGEABLE/,
|
||||
'the auto-merge must gate on the PR being MERGEABLE so a genuine divergence is left open for ' +
|
||||
'manual resolution rather than force-merged into main. See #2515.'
|
||||
);
|
||||
});
|
||||
|
||||
test('the auto-merge step is non-fatal (a published release stands even if it cannot complete)', () => {
|
||||
assert.ok(automerge, "expected the 'Auto-merge the release → main PR' step");
|
||||
assert.equal(
|
||||
automerge['continue-on-error'],
|
||||
true,
|
||||
'the merge-back auto-merge must be continue-on-error: the tag + npm publish already happened, ' +
|
||||
'so a merge-back that cannot complete (org policy, token) must not fail the release. See #2515.'
|
||||
);
|
||||
});
|
||||
|
||||
test('the auto-merge runs AFTER "Verify publish" (main only absorbs a published release)', () => {
|
||||
const verifyIdx = finalizeSteps.findIndex(
|
||||
(s) => typeof s.name === 'string' && s.name.includes('Verify publish')
|
||||
);
|
||||
const automergeIdx = finalizeSteps.findIndex(
|
||||
(s) => typeof s.name === 'string' && s.name.includes('Auto-merge the release')
|
||||
);
|
||||
assert.ok(verifyIdx !== -1, "expected a 'Verify publish' step");
|
||||
assert.ok(automergeIdx !== -1, "expected the 'Auto-merge the release → main PR' step");
|
||||
assert.ok(
|
||||
automergeIdx > verifyIdx,
|
||||
'the auto-merge must run after "Verify publish" so main never absorbs a release whose npm ' +
|
||||
'publish was not confirmed. Order is the invariant, not just a comment. See #2515.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user