Skip to content

fix(release): judge the release anchor against the commit its notes were built from - #619

Closed
Vivswan wants to merge 3 commits into
mainfrom
fix/release-anchor-base
Closed

Vivswan wants to merge 3 commits into
mainfrom
fix/release-anchor-base

Conversation

@Vivswan

@Vivswan Vivswan commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner

Before

release PR anchored at N -> a docs, test, or chore commit lands on main
  (a) -> anchor-check compares N with main's tip now                      -> red, and nothing can re-stamp
  (b) -> the old green stands -> merge -> boundary-check compares N with the merge's parent -> main red, no release
$ bun .github/scripts/release-pipeline.ts anchor-check      # (a) release branch at 7f51c4dc, main at 2982e7ea
release-pipeline anchor-check: last-release-sha in release-please-config.json is "08b057b5f858262cc43446a8f1946a25819d8af0", but main's tip is 2982e7ea757ff0dd245362ccf16b19c0fcc77a3e; the anchor is missing or stale, so merging would land a wrong boundary. [...]
exit 1
$ bun .github/scripts/release-pipeline.ts boundary-check    # (b) that branch squash-merged onto main
release-pipeline boundary-check: last-release-sha in release-please-config.json is "08b057b5f858262cc43446a8f1946a25819d8af0", but the newest release merge on main is <the merge> (parent 2982e7ea757ff0dd245362ccf16b19c0fcc77a3e); release PR refreshes would be computed from a stale boundary. [...]
exit 1

After

release PR anchored at N -> any commits land on main -> Update branch merges them in
  (a) -> anchor-check reads N from the branch's own chain                 -> green
  (b) -> merge -> boundary-check finds N inside the cycle the merge closed -> green
$ bun .github/scripts/release-pipeline.ts anchor-check      # (a)
the release PR carries this cycle's anchor: 08b057b5f858262cc43446a8f1946a25819d8af0
exit 0
$ bun .github/scripts/release-pipeline.ts boundary-check    # (b)
boundary is fresh: 08b057b5f858262cc43446a8f1946a25819d8af0
exit 0
anchor-check    old: last-release-sha == main's tip now
                new: last-release-sha == N, the main commit release-please built the PR on
boundary-check  old: last-release-sha == the newest release merge, or its parent
                new: last-release-sha == the newest release merge, or a commit of the cycle it closed
  • A second reason: under today's rule, a hand stamp at main's tip drops a visible commit never refreshed for.
  • Not in it: no automation, no change to when the hook runs.

How

  • N comes from the branch: the first parent of its release commit, above main.
  • The hook stamps N when N is the run's commit, wherever main's tip is.
  • Each refusal names its repair: the stamp command, or the rebuild for a branch that moved after its anchor.
  • Main's repair value: the newest commit the release's notes list.
  • Library: git's own log, rev-list, diff, and merge-base: these scripts run before bun install.

Proof

  • Tests: bun run build:schema && bun test test/scripts test/workflows gives 679 pass, 0 fail.
  • Red first: 33 of the 96 release-pipeline tests fail against main's anchor.ts.
  • Rehearsal: the blocks above are chore(main): release 3.0.1 #587's real release branch, in a scratch origin.
Technical details
  • Repeat the rehearsal from a clone with this branch checked out.
  • It fetches chore(main): release 3.0.1 #587's head into a scratch bare repository and never pushes.
R=$(mktemp -d); U=https://github.com/Vivswan/github-settings-as-code.git
git init -q --bare -b main "$R/origin.git"
git -C "$R/origin.git" fetch -q "$U" refs/pull/587/head
git -C "$R/origin.git" update-ref refs/heads/main 2982e7ea757ff0dd245362ccf16b19c0fcc77a3e
git -C "$R/origin.git" update-ref refs/heads/release-please--branches--main 7f51c4dcc3ff1e13413e03f1984a09331ae1f3e2
mkdir "$R/before" "$R/after"
git archive 2982e7ea757ff0dd245362ccf16b19c0fcc77a3e .github/scripts | tar -x -C "$R/before"
git archive HEAD .github/scripts | tar -x -C "$R/after"
run() { (cd "$R/$2" && bun "$R/$1/.github/scripts/release-pipeline.ts" "$3"; echo "exit $?"); }

git clone -q "$R/origin.git" "$R/pr"
git -C "$R/pr" checkout -q --detach origin/release-please--branches--main
run before pr anchor-check      # (a) before: exit 1
run after  pr anchor-check      # (a) after:  exit 0

git clone -q "$R/origin.git" "$R/main"
git -C "$R/main" merge -q --squash origin/release-please--branches--main >/dev/null
git -C "$R/main" -c user.name=rehearsal -c user.email=rehearsal@users.noreply.github.com commit -q -m "chore(main): release 3.0.1 (#587)"
run before main boundary-check  # (b) before: exit 1
run after  main boundary-check  # (b) after:  exit 0

git -C "$R/origin.git" update-ref refs/heads/release-please--branches--main 763fab11e755b55a75fce9ef50de13ba0a82b14a
git clone -q "$R/origin.git" "$R/pr587"
git -C "$R/pr587" checkout -q --detach origin/release-please--branches--main
run after pr587 anchor-check    # (c) #587's head, the hand stamp at main's tip: exit 1
git clone -q "$R/origin.git" "$R/main587"
git -C "$R/main587" merge -q --squash origin/release-please--branches--main >/dev/null
git -C "$R/main587" -c user.name=rehearsal -c user.email=rehearsal@users.noreply.github.com commit -q -m "chore(main): release 3.0.1 (#587)"
run after main587 boundary-check  # (d) main once #587 merges as it is: exit 0
rm -rf "$R"
  • What (c) prints is the refusal of an edited anchor: what is wrong, then one repair per line.
release-pipeline anchor-check: last-release-sha in release-please-config.json is "2982e7ea757ff0dd245362ccf16b19c0fcc77a3e", but release-please built this release PR on 08b057b5f858262cc43446a8f1946a25819d8af0: the anchor names another commit.
Stamp it from a clean checkout; the command leaves HEAD detached on the release PR branch, and its push re-runs the checks:
  GITHUB_SHA=08b057b5f858262cc43446a8f1946a25819d8af0 bun .github/scripts/release-pipeline.ts anchor
If it answers "already anchored", close and reopen the PR to run the checks on its head.
exit 1
  • A branch that moved after its anchor prints no stamp command, from the check and from the command alike.
  • The history is a scratch one: main M0 -> N (feat) -> V (feat), a release PR built and anchored on N (f522029e), then rebased onto V (4502d0d3).
$ bun .github/scripts/release-pipeline.ts anchor-check
release-pipeline anchor-check: last-release-sha in release-please-config.json is "f522029eb83a01e8936cb7d2513d2d8626085ad6", an older commit than 4502d0d3e4021fcafd7d1211e44ad07362b16b50, which this release PR is built on.
The branch moved after it was anchored, or the anchor was edited: the notes may lack the commits between the two, so no stamp is offered.
Delete the branch, and the next green push to main has release-please rebuild the release PR and the hook anchor it:
  git push origin --delete release-please--branches--main
exit 1
$ GITHUB_SHA=4502d0d3e4021fcafd7d1211e44ad07362b16b50 bun .github/scripts/release-pipeline.ts anchor
release-pipeline anchor: last-release-sha in release-please-config.json is "f522029eb83a01e8936cb7d2513d2d8626085ad6", an older commit than 4502d0d3e4021fcafd7d1211e44ad07362b16b50, which this release PR is built on.
[...]
exit 1
  • A lost anchor merged to main is told the newest commit the release's notes list, never the merge's parent.
  • The history is a scratch one: main M0 -> N (feat) -> V (feat) -> the release merge, the notes listing N (6860276d) alone.
$ bun .github/scripts/release-pipeline.ts boundary-check
release-pipeline boundary-check: last-release-sha in release-please-config.json is "0000000000000000000000000000000000000000", outside the cycle the newest release merge 17f24ee9e9cd8d47121942c1aa78bee58587f26f closed: an anchor was lost, or the value was edited.
Fix by PR: set last-release-sha to 6860276d40045e33b31f65a4d4a006dffef727b8, the newest commit release 1.1.0's notes list.
exit 1
  • Set by a commit, that value passes the check and leaves V after the boundary, so the next cycle lists it.

  • The merge's parent is V: it passes the check too, and no cycle would list V.

  • With no readable commit link in the release's changelog diff, the refusal names no sha and prints git show <the merge> -- CHANGELOG.md.

  • Repeat the red run: the committed tests against main's anchor.ts, in a scratch copy, give 63 pass and 33 fail.

T=$(mktemp -d)
git archive HEAD | tar -x -C "$T"
git show 2982e7ea757ff0dd245362ccf16b19c0fcc77a3e:.github/scripts/release-pipeline/anchor.ts > "$T/.github/scripts/release-pipeline/anchor.ts"
(cd "$T" && bun test test/scripts/release-pipeline.test.ts 2>&1 | grep -E "^\(fail\)|^ *[0-9]+ (pass|fail)$")
rm -rf "$T"
  • 17 of the 33 fail on behavior.
  • Main's code refuses the scope's cases (4), and stands down or stamps main's tip in the hook (2).
  • It passes a hand stamp at main's tip (1), and names no working repair (1).
  • It lacks the history, dirty-tree, and shape refusals (3).
  • It names the merge's parent as main's repair value (4), and overwrites a file of the operator's (2).
  • The other 16 fail only on words, which each test pins whole, except one that pins two fragments of its refusal.
  • 14 are refusals main also makes, in its own words.
  • 2 are the hook's unstamped answers: a returned reason and exit 0, not a refusal.

Still refused, each beside a control that passes

  • anchor-check, the stamp offered: a refresh that dropped the anchor.
  • anchor-check, the stamp offered: an anchor edited to main's tip, or to a sha on no history.
  • anchor-check, no stamp offered: an anchor older than the commit the branch is built on, by a rebase or by an edit.
  • anchor-check, no stamp offered: main merged in as the first parent, a release commit that is not on origin/main as fetched.
  • anchor-check: a shallow checkout.
  • boundary-check: a lost anchor merged to main, with the previous boundary at the previous merge's parent or at that merge.
  • boundary-check: an absent value once a release merge exists.
  • boundary-check, unchanged tests: a boundary newer than every release merge, a sha off the history, the pre-first-release cases.
  • The hook: a branch with no release commit, a checkout with uncommitted tracked changes.
  • The hook: a branch that moved after its anchor, and a shallow checkout that cannot tell whether it did.
  • The hook: a file of the operator's, untracked or ignored, at a path the release branch tracks.

Left unstamped by the hook, exit 0

  • A branch built on an older or a newer commit than the run's.

Recorded, not built

  • The platform's PR-side refresh rebuilds the branch and runs no hook, so that PR is unanchored until the hand command runs (managed file).
  • A visible commit can land between release-please's two reads inside one run, leaving the base ahead of the notes.
  • The hook does not stamp there, and an operator's hand stamp would be wrong only if that commit's own run never refreshes.
  • A branch rebased by hand while it has no anchor reads like a fresh rebuild, so the stamp is offered there.
  • A single-branch clone has no origin/main, so anchor-check cannot run there.
  • A branch moved backward by hand, onto an older commit of main, reads as an edited anchor, and the stamp it is offered repeats the entries in between in the next changelog.
  • One cycle of more than about 25,000 commits overflows the one git rev-list that boundary-check reads, and the check then fails naming that command.
  • A release commit whose subject names a pre-release version is not read as a release commit, as on main.
  • boundary-check still judges a range, though it could judge "at or after the newest listed commit" now that it reads the notes.
  • A release PR built on a release merge itself would fail boundary-check after its merge, and the platform proposes on no release merge's own run, so no such PR exists.
  • When the newest release merge is the first on the history, its cycle reaches back to the root.
  • A lost anchor there passes main's check if main already carried a boundary, and anchor-check on the PR still refuses it.
  • No test pins the anchor-check job's with: block: a dropped fetch-depth or ref fails on the next release PR, naming the cause.

Other facts

  • N is read on the branch's first-parent chain above main, bounded there so main's own release merges are never read.
  • One state function serves the check and the command, so the command never stamps what the check refuses.
  • No compatibility with a hand stamp at main's tip: chore(main): release 3.0.1 #587's head fails the new anchor-check (c), and main passes boundary-check once chore(main): release 3.0.1 #587 merges as it is (d).
  • The anchor-check job checks out the PR's head with fetch-depth: 0, so it runs the branch's copy of the script.
  • Unchanged: CONTRIBUTING.md and test/workflows/checks-workflow.test.ts state neither rule.
  • Gates: typecheck, lint, lint:arch, knip, check:compat, build:check, check:headers, and actionlint exit 0.
  • Published library: git diff --stat origin/main...HEAD -- src prints nothing.
  • Files: .github/scripts/release-pipeline/anchor.ts, .github/workflows/{checks.yml,update-release-pr.yml}, test/scripts/release-pipeline.test.ts.

…ere built from

A release PR carries release-please's next boundary (last-release-sha) in its branch. Both checks compared it with a commit that keeps moving, so one docs, test, or chore commit on main turned a correct release PR red, and no step of the pipeline could repair it.

- anchor-check: last-release-sha must be the main commit release-please built the branch on, read from the branch's own first-parent chain. It was main's tip at check time.
- boundary-check: last-release-sha must be the newest release merge or a commit of the cycle that merge closed. It was the merge or its parent.
- The hook stamps the commit the branch is built on when that is the run's commit, wherever main's tip is by then. It no longer stands down when main moved, and it refuses a checkout with uncommitted changes to tracked files.
- The anchor-check job checks out the release PR's head with its history: the merge ref at depth 1 does not hold the branch's chain.
- Each refusal names its repair: for the release PR the anchor command with the commit filled in, for main the value to set by PR.

A hand stamp at main's tip no longer passes anchor-check. With a changelog-visible commit that landed after the notes were built and was never refreshed for, that boundary drops the commit from every changelog.
Copilot AI balanced review requested due to automatic review settings November 12, 2026 19:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues are reported for the history-based release-anchor validation changes.

No open issues 🎉

What changed in this PR

This PR updates release-boundary validation to use the main commit the release PR was built on, rather than main's current tip. This keeps valid anchors accepted when later commits land and main is merged into the release branch.

Changes:

  • Derive the release PR anchor from its history and validate main's boundary against the cycle the newest release merge closed.
  • Check out the release PR's head with full history for anchor validation.
  • Add regression tests for moving main, incorrect anchors, branch shapes, shallow history, and repair commands.
File Description
test/​scripts/​release-pipeline.test.ts Adds anchor, boundary, refusal, and repair regression tests.
.github/​workflows/​update-release-pr.yml Clarifies the comment about restarting checks after anchoring.
.github/​workflows/​checks.yml Uses full PR-head history and updates validation descriptions.
.github/​scripts/​release-pipeline/​anchor.ts Implements history-based anchor checks, cycle validation, and actionable repair messages.

Review effort: Balanced

…s anchor, and name the newest listed commit as main's boundary

Two repairs the checks printed could drop a changelog entry when followed as printed. Each now names a repair that drops and repeats nothing.

- anchor-check and the anchor command read one state for a release PR that is not anchored. No anchor commit on the branch, or a value that is neither main's own nor an older commit: the stamp at the commit the branch is built on repairs it, as before. An anchor older than that commit means the release commit moved after it was stamped (a rebase onto a newer commit) or the value was edited back: the notes may lack the commits between the two, so the check names the rebuild alone, and the command refuses and pushes nothing. A shallow checkout that would have to tell an older commit from another one is refused.
- boundary-check keeps its verdict and changes the value its refusal names: the newest commit the merged release's notes list, read from the commit links the merge added to CHANGELOG.md, inside the cycle the merge closed. It named the merge's parent, which drops a visible commit that landed after the notes were built. With no readable link it names no sha and says where to read the notes.
- The anchor command checks the release PR branch out without --force and with --no-overwrite-ignore, so git refuses to overwrite a file of the operator's, untracked or ignored, at a path the branch tracks.
- Every refusal of the two checks and the hook has one shape: what is wrong on the first line, then one repair per line, a command alone on its own line. The refusal of a release commit that is not on origin/main says "as fetched here" and puts the fetch first.
Copilot AI balanced review requested due to automatic review settings November 12, 2026 20:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues are reported for the release-boundary validation changes.

No open issues 🎉

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings November 12, 2026 21:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues remain for the release-boundary validation changes.

No open issues 🎉

Review effort: Balanced

@Vivswan
Vivswan marked this pull request as ready for review November 12, 2026 21:29
@Vivswan

Vivswan commented Nov 12, 2026

Copy link
Copy Markdown
Owner Author

Closing in favor of a simpler design: release-please will find its previous release by a marker tag on main, so the anchor this PR repairs is deleted altogether. The branch stays for now, so this can be reopened if that change does not land.

@Vivswan Vivswan closed this Nov 12, 2026
@Vivswan
Vivswan deleted the fix/release-anchor-base branch November 13, 2026 02:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants