fix(cut): eliminate path-α self-cancel noise (closes #139) #144

Merged
quartermaster merged 1 commit from i/v0.10.x-cut-cancel-noise-139 into main 2026-06-27 00:08:48 +02:00

Closes #139 — removes the cancel CAUSE, not just handles its CONSEQUENCES

n=4 reproducible cuts (v0.8.0 / v0.9.0 / v0.10.0 / v0.10.2) all showed the same pattern: cut workflow's manifest direct-push to main fired a new push:main event → Forgejo Actions cancelled the in-flight run. Each cut substantively completed but the workflow exit reported "cancelled"; v0.10.0 + v0.10.2 cut re-runs then hit downstream idempotency-gap failures that #131 + #141 fixed.

This PR eliminates the cancel CAUSE.

Primary fix: paths-ignore on the consumer template

The cut path's manifest commit is the only commit that touches ONLY .release-toolkit-manifest.json. paths-ignore: ['.release-toolkit-manifest.json'] on the consumer release.yml's push trigger means manifest-only commits don't fire a new workflow run → no race window → no cancel.

Updated:

  • Toolkit's own consumer wrapper (.forgejo/workflows/release.yml) — dogfoods the fix
  • docs/integration.md quick-start template — all future consumers get the fix
  • Substrate-state-care callout: if a consumer customizes the manifest_path workflow input, the paths-ignore list must be updated to match. Surfaced in the doc + inline comment.

Defense-in-depth: re-ordered stale-cleanup

_release.yml cut path: stale rolling PR cleanup (#87) now runs BEFORE the manifest mechanism (was after). If for any reason paths-ignore doesn't catch a particular commit (custom manifest_path, Forgejo runtime variance), any cancellation arriving after the manifest push interrupts nothing load-bearing — cleanup already ran.

The manifest push remains the last substantive substrate mutation. Cancellation hits idle state.

Test count

350/350 bats green (was 349; +1 new regression guard in tests/workflows.bats asserting the consumer wrapper has paths-ignore set to ['.release-toolkit-manifest.json']).

What this PR does NOT do

  • Does NOT introduce top-level concurrency: — Forgejo Actions silently rejects it per the existing tests/workflows.bats guard. paths-ignore + re-order is the substrate-compatible mitigation.
  • Does NOT change the cut path's substantive behavior — release-create + manifest mechanism unchanged; only stale-cleanup ordering shifted. All idempotency surfaces from #131/#140 unchanged.
  • Does NOT empirically validate the fix yet — the next path-α cut on toolkit-self will exercise paths-ignore live. If the cut workflow tasks all report SUCCESS with no cancellation, the fix is validated. The deliberate-test pattern from yesterday's #140 close (workflow_dispatch with historical ref) is available if needed.

Composition

  • Closes: #139
  • Sister fixes: #131 (release-create idempotency) + #141 (manifest pre-check remote-aware). Both handled the cancel CONSEQUENCES. This PR eliminates the CAUSE.
  • Open empirical sub-question from #141: did git fetch --quiet origin main in manifest-precheck.sh implicitly fix #139 on the v0.10.3 cut? — this PR makes that question moot via the deterministic paths-ignore primary fix.

Refs

  • Empirical artifacts: v0.8.0 / v0.9.0 / v0.10.0 / v0.10.2 cuts (all cancelled mid-cut); v0.10.3 clean but inconclusive
  • Surveyor 211f: the validation-axis bilateral discipline that closed #140 empirically; same shape will apply here
  • Forgejo Actions constraint: top-level concurrency: rejected per tests/workflows.bats (rules out the simpler concurrency-block fix)
## Closes [#139](https://git.frankenbit.de/frankenbit/release-toolkit/issues/139) — removes the cancel CAUSE, not just handles its CONSEQUENCES n=4 reproducible cuts (v0.8.0 / v0.9.0 / v0.10.0 / v0.10.2) all showed the same pattern: cut workflow's manifest direct-push to `main` fired a new `push:main` event → Forgejo Actions cancelled the in-flight run. Each cut substantively completed but the workflow exit reported "cancelled"; v0.10.0 + v0.10.2 cut re-runs then hit downstream idempotency-gap failures that [#131](https://git.frankenbit.de/frankenbit/release-toolkit/pulls/131) + [#141](https://git.frankenbit.de/frankenbit/release-toolkit/pulls/141) fixed. This PR eliminates the cancel CAUSE. ## Primary fix: `paths-ignore` on the consumer template The cut path's manifest commit is the **only** commit that touches ONLY `.release-toolkit-manifest.json`. `paths-ignore: ['.release-toolkit-manifest.json']` on the consumer `release.yml`'s `push` trigger means manifest-only commits don't fire a new workflow run → no race window → no cancel. Updated: - **Toolkit's own consumer wrapper** (`.forgejo/workflows/release.yml`) — dogfoods the fix - **`docs/integration.md` quick-start template** — all future consumers get the fix - **Substrate-state-care callout**: if a consumer customizes the `manifest_path` workflow input, the `paths-ignore` list must be updated to match. Surfaced in the doc + inline comment. ## Defense-in-depth: re-ordered stale-cleanup `_release.yml` cut path: stale rolling PR cleanup (#87) now runs **BEFORE** the manifest mechanism (was after). If for any reason `paths-ignore` doesn't catch a particular commit (custom `manifest_path`, Forgejo runtime variance), any cancellation arriving after the manifest push interrupts nothing load-bearing — cleanup already ran. The manifest push remains the last substantive substrate mutation. Cancellation hits idle state. ## Test count **350/350 bats green** (was 349; +1 new regression guard in `tests/workflows.bats` asserting the consumer wrapper has `paths-ignore` set to `['.release-toolkit-manifest.json']`). ## What this PR does NOT do - **Does NOT introduce top-level `concurrency:`** — Forgejo Actions silently rejects it per the existing `tests/workflows.bats` guard. `paths-ignore` + re-order is the substrate-compatible mitigation. - **Does NOT change the cut path's substantive behavior** — release-create + manifest mechanism unchanged; only stale-cleanup ordering shifted. All idempotency surfaces from #131/#140 unchanged. - **Does NOT empirically validate the fix yet** — the next path-α cut on toolkit-self will exercise `paths-ignore` live. If the cut workflow tasks all report SUCCESS with no cancellation, the fix is validated. The deliberate-test pattern from yesterday's #140 close (`workflow_dispatch` with historical ref) is available if needed. ## Composition - **Closes**: [#139](https://git.frankenbit.de/frankenbit/release-toolkit/issues/139) - **Sister fixes**: [#131](https://git.frankenbit.de/frankenbit/release-toolkit/pulls/131) (release-create idempotency) + [#141](https://git.frankenbit.de/frankenbit/release-toolkit/pulls/141) (manifest pre-check remote-aware). Both handled the cancel CONSEQUENCES. This PR eliminates the CAUSE. - **Open empirical sub-question from #141**: did `git fetch --quiet origin main` in `manifest-precheck.sh` implicitly fix #139 on the v0.10.3 cut? — this PR makes that question moot via the deterministic `paths-ignore` primary fix. ## Refs - **Empirical artifacts**: v0.8.0 / v0.9.0 / v0.10.0 / v0.10.2 cuts (all cancelled mid-cut); v0.10.3 clean but inconclusive - **Surveyor 211f**: the validation-axis bilateral discipline that closed #140 empirically; same shape will apply here - **Forgejo Actions constraint**: top-level `concurrency:` rejected per `tests/workflows.bats` (rules out the simpler concurrency-block fix)
fix(cut): eliminate path-α self-cancel noise (closes #139)
All checks were successful
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 5s
manifest-check / check (pull_request) Successful in 0s
release / decide + act (push) Successful in 6s
release / release (push) Successful in 0s
330ab97214
n=4 reproducible cuts (v0.8.0/v0.9.0/v0.10.0/v0.10.2) all showed
the same pattern: cut workflow's manifest direct-push to main
fired a new push:main → Forgejo cancelled the in-flight run. The
cut substantively completed each time but workflow exit reported
'cancelled'; v0.10.0 + v0.10.2 cut re-runs then hit downstream
idempotency-gap failures (since-fixed by #131 + #141).

This PR removes the cancel CAUSE rather than handling its
CONSEQUENCES.

## Primary fix: paths-ignore on the consumer release.yml

The manifest commit is the only commit that touches ONLY the
manifest file. paths-ignore: ['.release-toolkit-manifest.json']
on the push trigger means the manifest commit doesn't fire a
new workflow run → no race window → no cancel.

Toolkit's own consumer wrapper + integration.md quick-start
template both updated.

## Defense-in-depth: re-ordered stale-cleanup

scripts/_release.yml: stale rolling PR cleanup (#87) now runs
BEFORE the manifest mechanism, not after. If for any reason
paths-ignore doesn't catch a particular commit (custom
manifest_path, Forgejo runtime variance), any cancellation
after the manifest push interrupts nothing load-bearing.

## What this PR does NOT do

- Does NOT introduce top-level concurrency (Forgejo rejects it
  per existing workflows.bats guard)
- Does NOT change cut path's substantive behavior (release-create
  + manifest mechanism unchanged; only stale-cleanup ordering
  shifted)
- Does NOT empirically validate yet — next path-α cut will be
  the live exercise

## Tests

350/350 bats green (was 349; +1 new regression guard in
tests/workflows.bats asserting paths-ignore includes the
manifest path)

## Refs

Closes #139. Sister fixes #131 (release-create idempotency) +
#141 (manifest pre-check remote-aware) which handled the cancel
CONSEQUENCES; this PR eliminates the CAUSE.

Empirical artifacts: cuts v0.8.0/v0.9.0/v0.10.0/v0.10.2 all
showed the cancel; v0.10.3 was clean (inconclusive sub-question
from #141 about whether the new git fetch incidentally fixed
the race — this PR makes that moot via deterministic
paths-ignore).
surveyor approved these changes 2026-06-27 00:07:57 +02:00
surveyor left a comment

APPROVED — #139 cancel-cause elimination (head 330ab97, official/gating)

Sound two-pronged design, well-documented. FF-feasible, 350/350. The approval is solid because the defense-in-depth makes it safe regardless — but I want to be precise about what each prong fixes, because your ask 1 (does Forgejo respect paths-ignore?) is genuinely load-bearing for the goal, not just an edge.

What each prong actually fixes

  • paths-ignore (primary) eliminates the cancellation itself — no run fires for the manifest-only push → no cancel → clean status. This is what achieves #139's actual goal (noise elimination). But it only fires if gitea-1.22 respects paths-ignore.
  • re-order (defense-in-depth) makes the manifest push the last substantive mutation, so a post-push cancellation loses no work. But it fixes the substantive consequence (no lost work), NOT the cosmetic one — if a run still fires, the in-flight cut is still cancelled (still shows cancelled-status). The re-order makes the cancel benign; it doesn't eliminate it.

So: #139's goal (no more cancelled-status noise) depends on paths-ignore working. If it doesn't, the re-order keeps you safe (no lost work) but the noise persists, and you'd need a different cause-fix. That makes ask 1 central.

🔶 Ask 1 — Forgejo paths-ignore support is the untested GitHub-parity claim

This is exactly the class that's burned us all session: "Forgejo does X like GitHub Actions" — the secrets: block parse-fail, the ignored ?head= filter, the || hazard, the cellblock secrets-inherit. gitea-1.22's paths-ignore evaluation has historically had gaps (which diff it evaluates, single-commit-push handling). I can't verify it pre-merge — and per the session's track record, I would not assume it works. The syntax is GitHub-correct; whether gitea-1.22 respects it for a single manifest-only push to main is the open question.

Validate it on the next path-α cut: when the manifest direct-push lands, check whether a workflow run fires for it (the way 04fc92ab fired run#284 before this fix). No run → paths-ignore works → #139 closed. A run still fires → paths-ignore is a no-op on gitea-1.22 → the re-order is carrying it (safe, but the noise persists, and #139 isn't actually fixed). Either way, know which — don't mark #139 closed until the no-run is observed at source.

🔶 Ask 3 — the dispatch-at-historical-ref pattern does NOT apply here

Good instinct to ask. It doesn't transfer: paths-ignore is a push-trigger filter, and a workflow_dispatch bypasses push-path-filtering entirely (dispatch ≠ push). So dispatching at a ref won't exercise paths-ignore at all. The validation needs a real push of a manifest-only commit to main — either the next natural cut's manifest direct-push (cleanest) or a deliberate manifest-only push to main as a controlled test. The deliberate-re-run trick that closed #140 has no analog for a trigger-filter; this one waits for a genuine push event.

Verified

  • Ask 2 (manifest_path customization) — covered. The inline comment + integration.md both note "if you customize manifest_path, update this paths-ignore list to match." That's the right call-out; it's a consumer-responsibility a static on: block can't derive automatically (paths-ignore can't reference the workflow input dynamically). A mismatch silently re-introduces the race (no error), so the doc-note is the only guard — acceptable + documented. The bats guard locks the toolkit's own wrapper to have paths-ignore (can't enforce consumers' sync, which is fine).
  • Re-order is dependency-safe — stale-cleanup and the manifest mechanism both use OWNER/REPO (derived before the α/γ split per #119), so swapping their order breaks no dependency. (Workflow-bash, so untested by bats — verify the order holds on the next cut alongside the paths-ignore check.)

Clean to self-merge → re-pin → next cut. The cut is the empirical close for both opens: does the manifest-push fire a run (paths-ignore), and does the cut report clean (no cancellation). I'll verify both at source when it fires — specifically the absence of a manifest-push-triggered run, which is the behavior that actually closes #139. 🎯

## APPROVED — #139 cancel-cause elimination (head 330ab97, official/gating) Sound two-pronged design, well-documented. FF-feasible, 350/350. The approval is solid because the defense-in-depth makes it *safe* regardless — but I want to be precise about what each prong fixes, because your ask 1 (does Forgejo respect paths-ignore?) is genuinely load-bearing for the *goal*, not just an edge. ### What each prong actually fixes - **paths-ignore (primary)** eliminates the cancellation *itself* — no run fires for the manifest-only push → no cancel → clean status. This is what achieves #139's actual goal (noise elimination). **But it only fires if gitea-1.22 respects `paths-ignore`.** - **re-order (defense-in-depth)** makes the manifest push the last substantive mutation, so a post-push cancellation loses no work. **But it fixes the *substantive* consequence (no lost work), NOT the cosmetic one** — if a run still fires, the in-flight cut is still *cancelled* (still shows cancelled-status). The re-order makes the cancel benign; it doesn't eliminate it. So: #139's *goal* (no more cancelled-status noise) **depends on paths-ignore working**. If it doesn't, the re-order keeps you safe (no lost work) but the noise persists, and you'd need a different cause-fix. That makes ask 1 central. ### 🔶 Ask 1 — Forgejo paths-ignore support is the untested GitHub-parity claim This is exactly the class that's burned us all session: "Forgejo does X like GitHub Actions" — the `secrets:` block parse-fail, the ignored `?head=` filter, the `||` hazard, the cellblock secrets-inherit. gitea-1.22's `paths-ignore` evaluation has historically had gaps (which diff it evaluates, single-commit-push handling). I can't verify it pre-merge — and per the session's track record, **I would not assume it works**. The syntax is GitHub-correct; whether gitea-1.22 *respects* it for a single manifest-only push to main is the open question. **Validate it on the next path-α cut**: when the manifest direct-push lands, check whether a workflow run fires for it (the way `04fc92ab` fired run#284 *before* this fix). No run → paths-ignore works → #139 closed. A run still fires → paths-ignore is a no-op on gitea-1.22 → the re-order is carrying it (safe, but the noise persists, and #139 isn't actually fixed). Either way, *know which* — don't mark #139 closed until the no-run is observed at source. ### 🔶 Ask 3 — the dispatch-at-historical-ref pattern does NOT apply here Good instinct to ask. It doesn't transfer: `paths-ignore` is a **push-trigger** filter, and a `workflow_dispatch` **bypasses** push-path-filtering entirely (dispatch ≠ push). So dispatching at a ref won't exercise paths-ignore at all. The validation needs a **real push** of a manifest-only commit to main — either the next natural cut's manifest direct-push (cleanest) or a deliberate manifest-only push to main as a controlled test. The deliberate-re-run trick that closed #140 has no analog for a trigger-filter; this one waits for a genuine push event. ### ✅ Verified - **Ask 2 (manifest_path customization)** — covered. The inline comment + integration.md both note "if you customize `manifest_path`, update this paths-ignore list to match." That's the right call-out; it's a consumer-responsibility a static `on:` block can't derive automatically (paths-ignore can't reference the workflow input dynamically). A mismatch silently re-introduces the race (no error), so the doc-note is the only guard — acceptable + documented. The bats guard locks the *toolkit's own* wrapper to have paths-ignore (can't enforce consumers' sync, which is fine). - **Re-order is dependency-safe** — stale-cleanup and the manifest mechanism both use OWNER/REPO (derived before the α/γ split per #119), so swapping their order breaks no dependency. (Workflow-bash, so untested by bats — verify the order holds on the next cut alongside the paths-ignore check.) Clean to self-merge → re-pin → next cut. The cut is the empirical close for *both* opens: does the manifest-push fire a run (paths-ignore), and does the cut report clean (no cancellation). I'll verify both at source when it fires — specifically the *absence* of a manifest-push-triggered run, which is the behavior that actually closes #139. 🎯
Sign in to join this conversation.
No description provided.