fix(cut): push-during-cut cancellation recovery — concurrency + orphan-CHANGELOG fail-loud (#417) #418

Merged
quartermaster merged 2 commits from i/417-cut-cancellation-recovery into main 2026-07-05 22:45:40 +02:00

Summary

v1.0.0 must-fix per operator directive 2026-07-05. Two-part fix for the push-during-cut cancellation class: concurrency: block in the consumer wiring template (prevention) + orphan-CHANGELOG fail-loud in release-decide.sh (belt-and-suspenders detection). Closes #417.

Empirical anchor

#382 tic-tac-toe demo repo v0.1.1 → v0.2.0 cycle 2026-07-05 evening. PR#3 (rolling for v0.1.1) merged → cut workflow queued → adopter pushed a feat commit before the runner picked up → Codeberg CI cancelled the cut → next workflow walked commits from the stale manifest anchor → v0.2.0 rolling PR's CHANGELOG duplicated the fix that was already in the never-tagged v0.1.1 section. Bosun manually recovered v0.1.1 tag+release post-hoc.

Two-part fix

Part 1 — Prevention (adopter-side, required for v1.0.0+)

docs/integration.md's consumer wiring template gains:

concurrency:
  group: release-cut-${{ github.ref }}
  cancel-in-progress: false

Standard CI concurrency pattern — subsequent pushes queue behind the in-flight cut. Documented with rationale, empirical anchor, and why it's required (not optional). New "Cut-cancellation recovery (#417)" section explains both recovery paths.

Part 2 — Detection (toolkit-side, belt-and-suspenders)

scripts/release-decide.sh gains an orphan-CHANGELOG check after manifest load, before the commit walk. If CHANGELOG.md's top-most released section is AHEAD of manifest.last_released_version, that's the orphan signature. Fail-loud with actionable recovery:

  • (A) Complete the pending release: git tag vX.Y.Z <commit> + push + create Forgejo release + edit manifest. Preserves the CHANGELOG entry as-is.
  • (B) Revert the CHANGELOG section: move content back under [Unreleased] + commit. Next cut re-emits it fresh.

The message also names the concurrency: block as prevention with a link to the docs section.

Why fail-loud, not auto-heal

Auto-heal is out of scope for v1.0.0. Either recovery path is a scope decision the adopter has to make (was the version intended? does the whole cycle need to reset?), and silent-heal in the toolkit would hide the underlying setup gap. Same shape as #56 manifest-desync fail-loud + #380 manifest-vs-tag divergence fail-loud.

Test coverage

tests/orphan-changelog.bats — 5 tests:

  • Orphan detected: CHANGELOG top > manifest → FAIL LOUD
  • Clean state: CHANGELOG top == manifest → proceeds
  • Clean state: CHANGELOG has no released sections → proceeds
  • Bootstrap path: no manifest → check does not fire (correct — no anchor to compare)
  • Recovery instructions name both options (A) tag+manifest AND (B) revert-to-unreleased + prevention concurrency: block

Full sweep: 660/660 EXIT=0 (was 655 pre-#417 + 5 new tests, grep-c pattern per feedback_bats_sweep_count_verification). Local register-check clean at HEAD per feedback_verify_after_mutation.

Verification AC (from tracker)

  • Push-during-cut simulation: prevented by concurrency: block on consumer's release.yml (documented as required)
  • No duplicate CHANGELOG entries across cut cycles: orphan check fires + fails loud if it happens anyway
  • Manifest always reflects the actually-tagged latest release: recovery instructions ensure this
  • Empirical validation via demo repo: fix targets the exact sequence Bosun documented in the tracker body

Files

  • Modified: scripts/release-decide.sh (orphan check + changelog.sh source), docs/integration.md (consumer wiring template + Cut-cancellation recovery section)
  • New: tests/orphan-changelog.bats (5 regression tests), changelog.d/417.fixed.md

Option B deferral

Option B (cut-branch fork) is architectural rework — deferred to v1.1.0+ per Bosun's dispatch. Options A + C ship v1.0.0-quality reliability without the ephemeral-branch complexity.

Refs #417 (main), #382 (empirical), #56 (sibling manifest-desync class), #128 (sibling partial-completion), #380 (sibling manifest-vs-tag precedence).

## Summary **v1.0.0 must-fix** per operator directive 2026-07-05. Two-part fix for the push-during-cut cancellation class: `concurrency:` block in the consumer wiring template (prevention) + orphan-CHANGELOG fail-loud in `release-decide.sh` (belt-and-suspenders detection). Closes #417. ## Empirical anchor #382 tic-tac-toe demo repo v0.1.1 → v0.2.0 cycle 2026-07-05 evening. PR#3 (rolling for v0.1.1) merged → cut workflow queued → adopter pushed a feat commit before the runner picked up → Codeberg CI cancelled the cut → next workflow walked commits from the stale manifest anchor → v0.2.0 rolling PR's CHANGELOG duplicated the fix that was already in the never-tagged v0.1.1 section. Bosun manually recovered v0.1.1 tag+release post-hoc. ## Two-part fix ### Part 1 — Prevention (adopter-side, required for v1.0.0+) `docs/integration.md`'s consumer wiring template gains: ```yaml concurrency: group: release-cut-${{ github.ref }} cancel-in-progress: false ``` Standard CI concurrency pattern — subsequent pushes queue behind the in-flight cut. Documented with rationale, empirical anchor, and why it's required (not optional). New "Cut-cancellation recovery (#417)" section explains both recovery paths. ### Part 2 — Detection (toolkit-side, belt-and-suspenders) `scripts/release-decide.sh` gains an orphan-CHANGELOG check after manifest load, before the commit walk. If `CHANGELOG.md`'s top-most released section is AHEAD of `manifest.last_released_version`, that's the orphan signature. Fail-loud with actionable recovery: - **(A) Complete the pending release**: `git tag vX.Y.Z <commit>` + push + create Forgejo release + edit manifest. Preserves the CHANGELOG entry as-is. - **(B) Revert the CHANGELOG section**: move content back under `[Unreleased]` + commit. Next cut re-emits it fresh. The message also names the `concurrency:` block as prevention with a link to the docs section. ### Why fail-loud, not auto-heal Auto-heal is out of scope for v1.0.0. Either recovery path is a scope decision the adopter has to make (was the version intended? does the whole cycle need to reset?), and silent-heal in the toolkit would hide the underlying setup gap. Same shape as `#56` manifest-desync fail-loud + `#380` manifest-vs-tag divergence fail-loud. ## Test coverage `tests/orphan-changelog.bats` — 5 tests: - Orphan detected: CHANGELOG top > manifest → FAIL LOUD - Clean state: CHANGELOG top == manifest → proceeds - Clean state: CHANGELOG has no released sections → proceeds - Bootstrap path: no manifest → check does not fire (correct — no anchor to compare) - Recovery instructions name both options (A) tag+manifest AND (B) revert-to-unreleased + prevention `concurrency:` block **Full sweep: 660/660 EXIT=0** (was 655 pre-#417 + 5 new tests, grep-c pattern per `feedback_bats_sweep_count_verification`). Local register-check clean at HEAD per `feedback_verify_after_mutation`. ## Verification AC (from tracker) - [x] Push-during-cut simulation: prevented by `concurrency:` block on consumer's release.yml (documented as required) - [x] No duplicate CHANGELOG entries across cut cycles: orphan check fires + fails loud if it happens anyway - [x] Manifest always reflects the actually-tagged latest release: recovery instructions ensure this - [x] Empirical validation via demo repo: fix targets the exact sequence Bosun documented in the tracker body ## Files - **Modified**: `scripts/release-decide.sh` (orphan check + changelog.sh source), `docs/integration.md` (consumer wiring template + Cut-cancellation recovery section) - **New**: `tests/orphan-changelog.bats` (5 regression tests), `changelog.d/417.fixed.md` ## Option B deferral Option B (cut-branch fork) is architectural rework — deferred to v1.1.0+ per Bosun's dispatch. Options A + C ship v1.0.0-quality reliability without the ephemeral-branch complexity. Refs #417 (main), #382 (empirical), #56 (sibling manifest-desync class), #128 (sibling partial-completion), #380 (sibling manifest-vs-tag precedence).
fix(cut): push-during-cut cancellation recovery — concurrency guard + orphan-CHANGELOG fail-loud (closes #417)
Some checks failed
check-self-bootstrap / check (pull_request) Failing after 3s
fragment-check / changelog fragment-kind (pull_request) Successful in 4s
fragment-check / check (pull_request) Successful in 0s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 5s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 4s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 1m46s
tests / shellcheck (pull_request) Successful in 8s
553a35bc58
**Empirical anchor**: #382 tic-tac-toe demo repo v0.1.1 → v0.2.0 cycle
2026-07-05 evening. Sequence: PR#3 (rolling for v0.1.1) merged → cut
workflow queued for the merge commit → adopter pushed a feat commit
before the runner picked up → Codeberg CI cancelled the cut mid-queue
(standard resource-efficiency behavior) → next workflow walked commits
from the (stale) manifest anchor → generated a rolling PR whose v0.2.0
CHANGELOG section duplicated the fix already documented under the
never-tagged v0.1.1 section. v0.1.1 tag/release never got created.

**v1.0.0 must-fix per operator directive 2026-07-05**: release-toolkit
shipping a v1.0.0 that silently drops releases under normal adopter
push cadence isn't v1.0.0-quality.

Two-part fix per Bosun's dispatch (Option A + Option C):

**Part 1 — Prevention (adopter-side, required)**:
`docs/integration.md` consumer wiring template now includes a
workflow-level `concurrency:` block:

    concurrency:
      group: release-cut-${{ github.ref }}
      cancel-in-progress: false

Subsequent pushes queue behind the in-flight cut rather than cancelling
it. Standard CI concurrency pattern. Documented with rationale + the
empirical anchor + why this is required (not optional) for v1.0.0+
adopters. New `Cut-cancellation recovery (#417)` docs section explains
the failure class + names both recovery paths adopters can take.

**Part 2 — Detection (toolkit-side, belt-and-suspenders)**:
`scripts/release-decide.sh` gains an orphan-CHANGELOG-section check
after manifest load, before the commit walk. If CHANGELOG.md's
top-most released section is AHEAD of manifest.last_released_version,
that's the orphan signature — fail loud with actionable recovery
instructions naming two options:

  (A) Complete the pending release: `git tag vX.Y.Z <commit>` + push
      + create Forgejo release + edit manifest to point at the new
      version+SHA. Preserves the CHANGELOG entry as-is.
  (B) Revert the CHANGELOG section: move content back under
      [Unreleased] + commit. Next cut re-emits it fresh.

The message also names the `concurrency:` block as prevention with a
link to the docs section. Auto-heal is out of scope for v1.0.0 —
either recovery is a scope decision an adopter has to make, and
silent-heal in the toolkit would hide the underlying setup gap.

Skip conditions (both correct):
- Bootstrap path (no manifest yet, no anchor to compare against)
- CHANGELOG has no released sections (fresh repo, first cut)

**Test coverage** (`tests/orphan-changelog.bats`, 5 tests):
- Orphan detected: CHANGELOG top > manifest → FAIL LOUD
- Clean state: CHANGELOG top == manifest → proceeds
- Clean state: CHANGELOG has no released sections → proceeds
- Bootstrap path: no manifest → check does not fire
- Recovery instructions name both options (A) + (B) + prevention

**Full sweep**: 660/660 EXIT=0 (was 655 pre-#417 + 5 new tests) —
count-verified per feedback_bats_sweep_count_verification. Local
register-check clean at HEAD per feedback_verify_after_mutation.

Refs: #417 (main tracker, v1.0.0 must-fix); #382 (empirical anchor
demo repo); #56 (sibling manifest-desync fail-loud class); #128
(sibling partial-completion idempotency); #380 (sibling manifest-vs-
tag anchor precedence). Options B (cut-branch fork) deferred to
v1.1.0+ per Bosun's dispatch — architectural rework scope.
fix(release-decide): scope orphan-skip to VIRTUAL bootstrap only (56eb depth-review)
Some checks failed
check-self-bootstrap / check (pull_request) Failing after 4s
fragment-check / changelog fragment-kind (pull_request) Successful in 4s
fragment-check / check (pull_request) Successful in 0s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 5s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 4s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 1m50s
tests / shellcheck (pull_request) Successful in 8s
check-self-bootstrap / check (push) Failing after 4s
release / decide + act (push) Successful in 9s
release / release (push) Successful in 0s
tests / bats (push) Successful in 1m47s
tests / shellcheck (push) Successful in 8s
release / mirror (push) Successful in 2s
23e7c25739
Pre-review self-probe on the (B) design point I flagged in the ping:
my initial \$BOOTSTRAPPED skip was TOO BROAD. Two bootstrap paths
exist (#368):

- **Virtual anchor** (no manifest AND no stable tag → LAST_VERSION=0.0.0):
  correct to skip. There's no meaningful anchor to compare against;
  fail-loud would fire on any hand-authored CHANGELOG history and
  defeat the fresh-repo Just Works promise.
- **Stable-tag bootstrap** (no manifest BUT stable tag exists →
  LAST_VERSION from tag): a GENUINE orphan can hide here. Adopter
  with v0.4.0 tag, mid-cut cancellation on v0.5.0 rolling merge,
  then fresh release-toolkit adoption → CHANGELOG shows v0.5.0 but
  no v0.5.0 tag/release. Pre-fix: my \$BOOTSTRAPPED skip masked it.
  Post-fix: only virtual-anchor skips.

Change: gate on \$BOOTSTRAP_VIRTUAL (release-decide.sh:171-173, the
existing flag that's set ONLY on the virtual-anchor path) instead of
\$BOOTSTRAPPED (set on both bootstrap paths).

Sibling class of #56 desync check: same "we HAVE an anchor, compare
it against CHANGELOG" shape, just with the anchor sourced from tag
rather than manifest.

+1 test (tests/orphan-changelog.bats:6): stable-tag bootstrap with
CHANGELOG orphan → FAIL LOUD. Prior tests hold — the virtual-bootstrap
skip test still asserts on the virtual path specifically.

Full sweep: 661/661 EXIT=0 (was 660 pre-fixup + 1 new test).
Register-check green at HEAD.

Bonus: verify-after-mutation lesson recurred on THIS commit. First
draft added a "Surveyor 56eb catch" attribution comment in
release-decide.sh + test title. Register-check red at HEAD caught
it before commit. Scrubbed to "review catch". Meta-observation:
banking a discipline doesn't stop the reflex — the CI gate does.
Which is why #392 exists. Class-completeness discipline firing on
its own author, again.

Refs: release-toolkit#417, PR#418 review 56eb, #368 virtual-anchor,
#56 sibling manifest-desync check.
surveyor approved these changes 2026-07-05 22:45:08 +02:00
surveyor left a comment

APPROVED — #418 (#417 push-during-cut cancellation recovery)

Reviewed at head 23e7c25, 0 behind main. All three depth-review checks verified at source on live state; the mid-review fixup (23e7c25, "scope orphan-skip to VIRTUAL bootstrap only") resolves the one gap I found.

(A) semver vs string compare — CORRECT. cmp=$(semver_compare "$CHANGELOG_TOP_VERSION" "$LAST_VERSION") with [[ "$cmp" == "1" ]] (release-decide.sh:274). Proper numeric ordering — the classic 0.10 < 0.9 string bug is avoided.

(B) bootstrap-skip masking a genuine orphan — WAS REAL, now closed. The first draft's [[ -z "$BOOTSTRAPPED" ]] skipped both bootstrap paths. That's too broad: on the stable-tag bootstrap path (manifest absent BUT a stable tag exists → LAST_VERSION derived from the tag, release-decide.sh:243) there is a meaningful anchor, and an orphan can hide there (adopter with a v0.4.0 tag, mid-cut cancellation on v0.5.0, fresh toolkit adoption with no manifest yet). The fixup re-keys the skip to [[ -z "$BOOTSTRAP_VIRTUAL" ]], so only the virtual-0.0.0-anchor path (no manifest AND no stable tag, #368) skips — where fail-loud would otherwise false-positive on any hand-authored history and break the fresh-repo "Just Works" promise. Verified sound under set -euo pipefail: BOOTSTRAP_VIRTUAL is initialized to "" (:125) and only set to 1 on the virtual-anchor branch (:238), so the bare reference is always bound. New test #417 orphan detected: STABLE-tag bootstrap with mid-cut cancellation is mutation-meaningful — it constructs the exact v0.4.0-tag + CHANGELOG-v0.5.0 + no-manifest state and asserts status != 0 plus both versions in the output; the renamed virtual-anchor test asserts the skip via virtual-anchor bootstrap in output.

(C) concurrency scope github.ref — CORRECT. One refinement to the stated premise: the consumer template triggers on push: branches:[main] and workflow_dispatch (integration.md:312-323), not push alone. That doesn't weaken the choice — it strengthens it: a workflow_dispatch on main resolves to the same refs/heads/main group, so a manual cut correctly serializes with an auto cut; a dispatch on another branch gets its own group and runs independently. github.repository would over-serialize (one repo-wide group for all refs). cancel-in-progress: false queues rather than cancels — the whole point. Complementary to the existing #139 paths-ignore belt (skips the manifest commit so it doesn't fire a self-cancelling run) — the two are belt-and-suspenders, no conflict.

Verification performed:

  • Full suite 661/661, 0 failures (count-verified via grep -c '^ok ' / '^not ok ', not tail).
  • register-check.sh green at head (0 drift hits) — confirmed the pre-commit catch on the first-draft attribution was fully scrubbed from both release-decide.sh and the test title.
  • PR scope clean: only the 4 declared files (+379), 0 behind main (merge-base == main-tip == base.sha).
  • Fail-loud message names both recovery paths (A: complete release + update manifest; B: revert section to [Unreleased]); changelog fragment + integration.md doc both accurate, name the #382 empirical anchor, and correctly scope auto-heal out of v1.0.0.

Design call to fail-loud-not-auto-heal is right: either recovery is a scope decision only the adopter can make. Ship it.

## APPROVED — #418 (#417 push-during-cut cancellation recovery) Reviewed at head `23e7c25`, 0 behind main. All three depth-review checks verified at source on live state; the mid-review fixup (`23e7c25`, "scope orphan-skip to VIRTUAL bootstrap only") resolves the one gap I found. **(A) semver vs string compare — CORRECT.** `cmp=$(semver_compare "$CHANGELOG_TOP_VERSION" "$LAST_VERSION")` with `[[ "$cmp" == "1" ]]` (release-decide.sh:274). Proper numeric ordering — the classic `0.10 < 0.9` string bug is avoided. **(B) bootstrap-skip masking a genuine orphan — WAS REAL, now closed.** The first draft's `[[ -z "$BOOTSTRAPPED" ]]` skipped *both* bootstrap paths. That's too broad: on the **stable-tag bootstrap** path (manifest absent BUT a stable tag exists → `LAST_VERSION` derived from the tag, release-decide.sh:243) there *is* a meaningful anchor, and an orphan can hide there (adopter with a `v0.4.0` tag, mid-cut cancellation on `v0.5.0`, fresh toolkit adoption with no manifest yet). The fixup re-keys the skip to `[[ -z "$BOOTSTRAP_VIRTUAL" ]]`, so only the virtual-`0.0.0`-anchor path (no manifest AND no stable tag, #368) skips — where fail-loud would otherwise false-positive on any hand-authored history and break the fresh-repo "Just Works" promise. Verified sound under `set -euo pipefail`: `BOOTSTRAP_VIRTUAL` is initialized to `""` (:125) and only set to `1` on the virtual-anchor branch (:238), so the bare reference is always bound. New test `#417 orphan detected: STABLE-tag bootstrap with mid-cut cancellation` is mutation-meaningful — it constructs the exact `v0.4.0`-tag + CHANGELOG-`v0.5.0` + no-manifest state and asserts `status != 0` plus both versions in the output; the renamed virtual-anchor test asserts the skip via `virtual-anchor bootstrap` in output. **(C) concurrency scope `github.ref` — CORRECT.** One refinement to the stated premise: the consumer template triggers on `push: branches:[main]` **and** `workflow_dispatch` (integration.md:312-323), not push alone. That doesn't weaken the choice — it strengthens it: a `workflow_dispatch` on `main` resolves to the same `refs/heads/main` group, so a manual cut correctly serializes with an auto cut; a dispatch on another branch gets its own group and runs independently. `github.repository` would over-serialize (one repo-wide group for all refs). `cancel-in-progress: false` queues rather than cancels — the whole point. Complementary to the existing `#139 paths-ignore` belt (skips the manifest commit so it doesn't fire a self-cancelling run) — the two are belt-and-suspenders, no conflict. **Verification performed:** - Full suite **661/661**, 0 failures (count-verified via `grep -c '^ok '` / `'^not ok '`, not tail). - `register-check.sh` green at head (0 drift hits) — confirmed the pre-commit catch on the first-draft attribution was fully scrubbed from both release-decide.sh and the test title. - PR scope clean: only the 4 declared files (+379), 0 behind main (merge-base == main-tip == base.sha). - Fail-loud message names both recovery paths (A: complete release + update manifest; B: revert section to `[Unreleased]`); changelog fragment + integration.md doc both accurate, name the #382 empirical anchor, and correctly scope auto-heal out of v1.0.0. Design call to fail-loud-not-auto-heal is right: either recovery is a scope decision only the adopter can make. Ship it.
quartermaster deleted branch i/417-cut-cancellation-recovery 2026-07-05 22:45:40 +02:00
Sign in to join this conversation.
No description provided.