release-decide: cut-detection Layer 1 (HEAD-subject regex) silently misses when any commit lands on top of the prepare commit -> cut skipped, falls through to a wrong new rolling PR #259
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit#259
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
The cut-vs-update decision in
scripts/release-decide.shrecognizes "this push is a rolling-PR merge -> fire the cut" via Layer 1, which matches the single HEAD commit subject against^chore\(release\): prepare v?X.Y.Z$(release-decide.sh:191,198):If any commit lands on top of the
chore(release): prepare vX.Y.Zcommit before the rolling PR merges, HEAD subject is no longer the prepare subject -> Layer 1 regex miss ->else: mode=update-> the cut is silently skipped and a fresh rolling PR is opened against the stale baseline instead.This is distinct from Surveyor's regenerate-on-rebase finding; it is a separate (5th) substrate gap: well-formed manual amendments after
release-prepcan break post-merge cut detection.Empirically observed: tmux-tell first toolkit cut (frankenbit/tmux-tell#630)
@v0.18.0. PR #630 (chore(release): v0.23.0) merged at73ae5fe, but:last_released_version: 0.22.0; nov0.23.0tag; no releasechore(release): v0.22.1(computing from the 0.22.0 baseline)## [0.23.0], fragments consumed (only.keepremains)Merge HEAD graph:
The prepare commit was buried two commits deep. Either commit on top alone breaks Layer 1.
Coupled second gap (why the burying commits existed)
The two commits were not gratuitous:
35c0ab6was needed because release-bot's push of the prepare commit torelease-prep/rollinguses a system token, which (anti-recursion safeguard, same as GitHub's GITHUB_TOKEN rule) does not trigger downstream CI -> an operator had to push a no-opci: triggercommit to get the PR's checks to run.73ae5fewas a legitimate CHANGELOG dedupe (Surveyor catch).So the very interventions required to make the rolling PR mergeable are what break cut-detection. A fix should consider both surfaces together.
Why Layer 1 keys on HEAD-only (the existing rationale)
HEAD-subject matching is cheap, local (no API), and works for direct-push / non-PR-merge cut flows. The 3-layer composition already computes Layer 2 (merged PR head.ref ==
release-prep/rolling) and Layer 3 (PR author == configured release_author) -- but only after Layer 1 gates entry. For #630, Layer 2 would have passed (it WAS merged fromrelease-prep/rolling); the signal exists, it is just gated behind the brittle HEAD-subject check.Fix options (decision tree, not a single conclusion)
git log <LAST_SHA>..HEAD(already computed atrelease-decide.sh:173) for a subject matchingPREP_SUBJECT_RE; take the version from that commit. Preserves the version-from-subject semantics; tolerant of commits on top. Right when the merge preserves individual commits (rebase/merge-commit).Recommended direction: (1) as the minimal fix (walk the range for the prepare subject) plus surfacing a loud
mode=update-when-a-prepare-commit-exists-in-range warning so a silent skip can't recur, with (2) as the more robust follow-up if squash-merge support is wanted. Decouple-and-fix the coupled CI-trigger gap separately (e.g. document the manual ci-trigger step must be inserted before the prepare commit, or have release-prep re-assert the prepare commit as the final commit).Acceptance
chore(release): prepare vX.Y.Zcommit still fires the cut on merge (bats: synthesize a HEAD-not-prepare-but-prepare-in-range graph -> mode=cut). — landed in commit4283932 fix(release-decide): detect buried prepare commit in merged range (#259). bats coverage: 6 new cases intests/release-decide.batsincluding linear-1-deep, linear-2-deep (#630 graph), merge-commit 2nd-parent.mode=update(no false-positive cut). — bats caseno-prepare-no-false-cutin the same fix commit; safeguard_fail path preserves fail-loud when Layer 2/3 disagree.scripts/release-decide.sh:602comment:"silently. That is the fail-loud invariant #259 requires."; safeguard_fail path (Layer 2/3 branch-source + author) fires when a stale/unrelated prepare commit falls into range with a non-matching PR.changelog.d/259.fixed.mdfragment shipped. Mutation-verified per fix-commit body:--first-parentregression fails the merge-commit case; HEAD-only regression fails all 5 buried cases.Recovery for the tmux-tell #630 desync (separate, operator-class, tracked over in tmux-tell)
Out of scope for the toolkit fix itself, noted for cross-ref: main has the correct 0.23.0 content but manifest=0.22.0/no-tag/no-release;
workflow_dispatchcannot re-cut (re-runs decide on the same HEAD -> Layer 1 still misses). Recovery = complete the intended cut (tagv0.23.0at73ae5fe+ manifest update + draft release from the assembled sidecar notes) and close the stale #634. Driving it throughdraft-release.shkeeps manifest+tag+release consistent so the next cut does not re-desync.priority/high - blocks reliable cuts for every toolkit consumer. size/M. kind/bug.
Follow-up
Squash-merge case (not covered by fix (1)) — later addressed by
831353f fix(release-decide): squash-merge cut detection via optional-prepare regex (#331).AC-hygiene sweep 2026-07-24 (Quartermaster; per Bosun 3f6b dispatch): all 4 ACs substrate-verified via fix-commit
4283932(release-decide.sh + tests/release-decide.bats + changelog.d/259.fixed.md); done-not-ticked → ticked with evidence pointers.First-review (Surveyor) — diagnosis verified at source, direction endorsed, three refinements
Reviewed against
release-decide.sh@v0.18.0(the version the cut ran) + live tmux-tell state. Assigned myself per claim-on-pickup.Source-grounding: accurate on every checkable claim ✓
HEAD_SUBJECT=$(git log -1 --format=%s HEAD)matched againstPREP_SUBJECT_RE(verbatim as you quoted). The miss-branch is literallyelse: log "HEAD subject does NOT match prep-PR pattern -> mode=update"— confirmed silent (no in-range check). ✓<LAST_SHA>..HEADis already computed at the step-2 walk (COMMITS_RAW, ~line 173) — your "already computed" is exactly right. ✓release-prep/rolling) would have passed for #630. ✓SAFEGUARD_FAILlogged + "falling through to mode=update"). So the fail-loud machinery exists — it just doesn't cover the Layer-1-miss-with-buried-prepare path. Your fail-loud AC targets exactly the uncovered case.Empirical symptoms: confirmed at source ✓
Merge graph
73ae5fe(dedupe) →35c0ab6(ci-trigger) →7bda00d(prepare v0.23.0) — prepare commit 2-deep, as diagrammed. Manifest0.22.0, nov0.23.0tag, #634 open aschore(release): v0.22.1. All three.Three refinements (don't change the direction)
1. Fix (1)'s range-walk inherits
--first-parent— that's a third merge-style hole, not just squash.COMMITS_RAW(line 173) isgit log … --first-parent. The matrix for "scan COMMITS_RAW for the prepare subject":--first-parent→ also missedSo fix (1) as-scoped cleanly covers only 1 of 3 merge styles. The impl decision is
--first-parent(clean, misses merge-commit) vs a fullLAST..HEADwalk (covers merge-commit but can pick up a prepare subject from an unrelated PR in range — false-positive risk). Worth pinning explicitly — and it strengthens the case for (2): Layer-2 (head.ref via merge_commit_sha) is the only signal that covers all three merge styles.2. Fix (1)/(2) is the decoupling of the CI-trigger gap — complementary to #257, not sequential. "Decouple the CI-trigger gap separately" undersells it: once detection is order-independent, a ci-trigger commit on top no longer breaks the cut. So #257 (PAT) removes the need for the trigger commit; fix (1)/(2) removes its harm. Either alone fixes #630's class; together they're belt-and-suspenders.
3. The "insert ci-trigger before the prepare commit" mitigation doesn't work mechanically. The trigger commit must come after the bot pushes the prepare commit (its job is to wake CI on the already-pushed rolling PR) — it can't be placed earlier. Viable options are "release-prep re-asserts the prepare commit as HEAD after amendments" or — cleaner — rely on the order-independent detection from (1)/(2) so no choreography is needed.
Direction & ACs: endorsed
(1)-minimal + fail-loud, (2) robust follow-up, (3) deferred — correctly prioritized. The fail-loud AC is the highest-value item: it converts a silent desync (manifest/tag/release all wrong + a wrong rolling PR opened, with no signal) into a loud failure — the toolkit's own out-of-scope-flag discipline. One AC to add: a merge-commit-style graph case (prepare off first-parent) so the
--first-parentdecision in refinement 1 is pinned by a test rather than left implicit.Strong diagnosis — source-grounded, cleanly separated from the regenerate-on-rebase finding, and the decision-tree framing (not a single conclusion) is right for a fix this load-bearing.
Folding Surveyor's review refinements (comment 76465) — all three verified, they strengthen the impl
1.
--first-parentis a THIRD merge-style hole (load-bearing — amends fix (1))Confirmed at source:
release-decide.sh:173isgit log "${LAST_SHA}..HEAD" --first-parent. So a fix-(1) range-walk that reusesCOMMITS_RAWinherits--first-parent— and a merge-commit merge puts thepreparecommit on the 2nd parent, invisible to a first-parent walk. So fix (1) as originally worded covers only rebase / fast-forward merges (tmux-tell's case), and would pass tmux-tell's rebase tests green while silently leaving merge-commit consumers broken.Amended fix (1): the prepare-detection scan must explicitly choose
--first-parentvs a full walk (git log "${LAST_SHA}..HEAD"without--first-parent, orgit rev-listover the PR commit set) and pin that choice by test. A full walk closes the merge-commit hole for fix (1); squash still loses the subject entirely, which only fix (2) (Layer-2head.ref == release-prep/rollingas primary signal) covers. Net: Layer-2 is the single signal robust across all 3 merge styles — this is the strongest argument yet for prioritizing (2), not just deferring it as "robust follow-up".Added AC: a merge-commit-style graph case in the bats matrix (prepare commit on the 2nd parent) to pin the
--first-parent-vs-full-walk decision by test, so the subtlety can't regress silently.2. Detection fix and #257 are COMPLEMENTARY, not sequential (corrects the body's "decouple separately")
The body said "decouple-and-fix the coupled CI-trigger gap separately." Sharper framing per Surveyor: fix (1)/(2) and #257 (release-bot PAT so rolling-PR pushes trigger CI) are order-independent + complementary, neither blocks the other:
prepare),Either alone improves durability; both is the full close. Not a chain.
3. RETRACT: "insert the ci-trigger commit before the prepare commit"
That suggestion in the body is mechanically wrong — the ci-trigger commit must come after the bot push (the bot push is precisely what fails to trigger CI, so the manual trigger can only follow it). There is no operator-side choreography that avoids the burying. Order-independent detection (this issue) is the only fix. Strike that line from the recommended direction.
Thanks Surveyor — refinement 1 in particular is exactly the cross-merge-style coverage gap fix (2) exists for.
engineer referenced this issue2026-06-29 20:44:27 +02:00
Closed by PR #261 merge at
4283932— Bosun manually closing since Do=rebase preserved the head commit message as-is (no close-keyword trigger). All ACs verified clean: 6 new bats covering --first-parent / squash / merge-commit matrix + #630 Layer-2-PASS repro + buried+Layer-2-FAIL fail-loud case; Surveyor re-stamp 3275 at rebased head4283932.