chore(release): v0.11.0 #145

Merged
alex merged 1 commit from release-prep/rolling into main 2026-06-27 00:53:37 +02:00
Member

Added

Structural backstop for self-bootstrap re-pin (#124).

Per Surveyor's 116b/cf2c/fc7d framing: the self-bootstrap re-pin discipline (AGENTS.md §2) is sound in principle but at-action-time engagement has slipped twice (v0.8.0 + v0.9.0 cuts both hit the same OWNER-unbound bug because the fix on main wasn't in the pinned ref). I've embodied the discipline manually 10 times across this session via the in-cycle re-pin pattern; this PR automates it as a check-or-fail.

How it works

scripts/check-self-bootstrap.sh — compares each compose-script's blob hash at HEAD vs at the pinned toolkit_ref. Exits 1 if any differ, listing which.

Compose-scripts list (the load-bearing surfaces from AGENTS.md §2):

  • .forgejo/workflows/_release.yml
  • scripts/release-decide.sh, release-prep.sh, draft-release.sh, manifest-precheck.sh, manifest-check.sh
  • scripts/lib/forgejo-api.sh, fragments.sh, changelog.sh, conventional-commits.sh, config.sh, semver.sh

The toolkit_ref is read from .forgejo/workflows/release.yml (via yq with a grep+sed fallback). Test seam PINNED_REF_OVERRIDE lets bats simulate any pinned ref state.

.forgejo/workflows/check-self-bootstrap.yml — runs the script on every PR-to-main + push:main. The push:main run uses paths-ignore: ['.release-toolkit-manifest.json'] matching the consumer template's #139 fix (manifest-only commits don't trigger the check either).

What this would have prevented

  • v0.8.0 cut OWNER-unbound failure (would have failed on #119's merge to main: "compose-scripts on main differ from pinned v0.8.0-rc.1 — tag a new rc.N and re-pin before next cut")
  • v0.9.0 cut OWNER-unbound failure (same pattern)
  • The 10 in-cycle re-pins I've embodied manually this session (#127/#133/#138/#143/#146/etc.) — operator would have seen the red workflow run immediately after each compose-script PR merge, surfacing the re-pin need at action-time

Tests

360/360 bats green (was 350; +10 new tests in tests/check-self-bootstrap.bats):

Scenario Expected
All compose-scripts at HEAD match pinned ref exit 0 ("OK")
One script differs at HEAD exit 1, name listed in output
Multiple scripts differ exit 1, all names listed
New script on HEAD (not in pinned ref) exit 1, flagged "NEW"
Script in COMPOSE_SCRIPTS that doesn't exist on either ref skip silently (no false positive)
Pinned ref doesn't resolve as a git ref fail loud ("does not resolve")
release.yml missing + no PINNED_REF_OVERRIDE fail loud ("not found")
Reads toolkit_ref from release.yml when no override set extracted correctly
Retired script (gone from HEAD, still at pinned ref) does NOT trigger fail (graceful)

The git cat-file -e existence check was a subtle implementation choice: git rev-parse outputs the input revision string on stdout even when the path doesn't exist, so the empty-string-detection-on-rev-parse-output approach was unreliable; cat-file -e cleanly separates existence from hash retrieval.

What this PR does NOT do

  • Does NOT block PRs that introduce compose-script changes. PRs introducing such changes are EXPECTED to fail the check (they ARE the substrate change). The check fires red on push:main AFTER merge, surfacing the re-pin need to the operator at that moment.
  • Does NOT auto-create a re-pin PR. The original tracker considered an auto-re-pin trigger (option (B) in #124 body) and rejected it: more machinery, more failure modes, harder to reason about timing relative to the cut sequence. The check-and-fail approach is simpler — surfaces the problem, lets operator engage.
  • Does NOT close the empirical re-pin discipline. The operator-side discipline (engage in-cycle) remains the practice; the check is the safety net for when it slips.
  • Does NOT apply to external consumers. Their release.yml's toolkit_ref pins external tagged toolkit code that doesn't drift relative to their main; the check is toolkit-self-specific.

Refs

  • Closes: #124

  • Surveyor 116b/cf2c/fc7d: the forward-thought + repeated calibration that filed and refined the tracker

  • Empirical artifacts: v0.8.0 + v0.9.0 OWNER-unbound failures; 10 in-cycle re-pins this session

  • Related: AGENTS.md §2 (mechanism-of-touch); #139 (paths-ignore for manifest-only commits)

  • structural backstop for self-bootstrap re-pin (closes #124)

Fixed

Path-α self-cancel noise eliminated (#139).

The phenomenon (n=4 reproducible streak)

Across v0.8.0, v0.9.0, v0.10.0, v0.10.2 cuts, the same pattern fired:

  1. Cut workflow ran under path-α; release-create + manifest direct-push to main succeeded
  2. The manifest push fired a new push:main event
  3. Forgejo Actions cancelled the in-flight run in favor of the new one
  4. Workflow exit reported "cancelled" instead of "success" — operator confused; substrate state was actually correct

v0.10.0 even amplified the noise: operator's manual re-run hit the release-create 409 conflict (pre-#131 idempotency), making it look like a real failure. v0.10.2 hit the manifest non-fast-forward push (pre-#140 remote-aware pre-check).

Primary fix: paths-ignore

docs/integration.md quick-start template now shows:

on:
  push:
    branches: [main]
    paths-ignore: ['.release-toolkit-manifest.json']

The toolkit's own consumer wrapper (.forgejo/workflows/release.yml) updated the same way. The cut path's manifest commit is the only commit that touches ONLY the manifest — so excluding .release-toolkit-manifest.json from the push trigger means the manifest commit doesn't fire a new workflow run. The race window goes away entirely.

Substrate-state-care call-out: if a consumer customizes the manifest_path workflow input, the paths-ignore list must be updated to match. The integration.md docs surface this explicitly.

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

_release.yml cut path: the stale-rolling-PR cleanup step (#87) now runs BEFORE the manifest write+commit+push (was after). This means:

  • If for any reason paths-ignore doesn't catch the manifest commit (custom manifest_path, Forgejo runtime variance, etc.)
  • A cancellation arriving after the manifest push interrupts nothing load-bearing — the cleanup ran first

The manifest push remains the last substantive substrate mutation in the cut path, so any cancellation hits idle workflow 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 concurrency: at the workflow level — Forgejo Actions silently rejects top-level concurrency 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 the ordering of stale-cleanup relative to manifest push 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 (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 of which handled the re-run-after-cancel CONSEQUENCES; this fix eliminates the cancel CAUSE
  • Open empirical sub-question from #141: did the git fetch --quiet origin main in manifest-precheck.sh implicitly fix #139 on the v0.10.3 cut? — this PR makes that question moot; paths-ignore is the deterministic 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 (n=1 vs n=4)

  • Surveyor 211f: the validation-axis bilateral discipline that turned the partial-completion matrix's empirical-close-pending into verified

  • Forgejo Actions constraint: top-level concurrency: rejected per tests/workflows.bats (rules out the simpler concurrency-block fix)

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

### Added **Structural backstop for self-bootstrap re-pin** ([#124](https://git.frankenbit.de/frankenbit/release-toolkit/issues/124)). Per Surveyor's 116b/cf2c/fc7d framing: the self-bootstrap re-pin discipline (AGENTS.md §2) is sound *in principle* but at-action-time engagement has slipped twice (v0.8.0 + v0.9.0 cuts both hit the same OWNER-unbound bug because the fix on main wasn't in the pinned ref). I've embodied the discipline manually 10 times across this session via the in-cycle re-pin pattern; this PR automates it as a check-or-fail. ## How it works **`scripts/check-self-bootstrap.sh`** — compares each compose-script's blob hash at `HEAD` vs at the pinned `toolkit_ref`. Exits 1 if any differ, listing which. **Compose-scripts list** (the load-bearing surfaces from AGENTS.md §2): - `.forgejo/workflows/_release.yml` - `scripts/release-decide.sh`, `release-prep.sh`, `draft-release.sh`, `manifest-precheck.sh`, `manifest-check.sh` - `scripts/lib/forgejo-api.sh`, `fragments.sh`, `changelog.sh`, `conventional-commits.sh`, `config.sh`, `semver.sh` The toolkit_ref is read from `.forgejo/workflows/release.yml` (via `yq` with a `grep`+`sed` fallback). Test seam `PINNED_REF_OVERRIDE` lets bats simulate any pinned ref state. **`.forgejo/workflows/check-self-bootstrap.yml`** — runs the script on every PR-to-main + push:main. The push:main run uses `paths-ignore: ['.release-toolkit-manifest.json']` matching the consumer template's #139 fix (manifest-only commits don't trigger the check either). ## What this would have prevented - v0.8.0 cut OWNER-unbound failure (would have failed on #119's merge to main: "compose-scripts on main differ from pinned v0.8.0-rc.1 — tag a new rc.N and re-pin before next cut") - v0.9.0 cut OWNER-unbound failure (same pattern) - The 10 in-cycle re-pins I've embodied manually this session (#127/#133/#138/#143/#146/etc.) — operator would have seen the red workflow run immediately after each compose-script PR merge, surfacing the re-pin need at action-time ## Tests **360/360 bats green** (was 350; +10 new tests in `tests/check-self-bootstrap.bats`): | Scenario | Expected | |---|---| | All compose-scripts at HEAD match pinned ref | exit 0 ("OK") | | One script differs at HEAD | exit 1, name listed in output | | Multiple scripts differ | exit 1, all names listed | | New script on HEAD (not in pinned ref) | exit 1, flagged "NEW" | | Script in COMPOSE_SCRIPTS that doesn't exist on either ref | skip silently (no false positive) | | Pinned ref doesn't resolve as a git ref | fail loud ("does not resolve") | | `release.yml` missing + no `PINNED_REF_OVERRIDE` | fail loud ("not found") | | Reads `toolkit_ref` from `release.yml` when no override set | extracted correctly | | Retired script (gone from HEAD, still at pinned ref) | does NOT trigger fail (graceful) | The `git cat-file -e` existence check was a subtle implementation choice: `git rev-parse` outputs the input revision string on stdout even when the path doesn't exist, so the empty-string-detection-on-rev-parse-output approach was unreliable; `cat-file -e` cleanly separates existence from hash retrieval. ## What this PR does NOT do - **Does NOT block PRs** that introduce compose-script changes. PRs introducing such changes are EXPECTED to fail the check (they ARE the substrate change). The check fires red on push:main AFTER merge, surfacing the re-pin need to the operator at that moment. - **Does NOT auto-create a re-pin PR**. The original tracker considered an auto-re-pin trigger (option (B) in #124 body) and rejected it: more machinery, more failure modes, harder to reason about timing relative to the cut sequence. The check-and-fail approach is simpler — surfaces the problem, lets operator engage. - **Does NOT close the empirical re-pin discipline**. The operator-side discipline (engage in-cycle) remains the practice; the check is the safety net for when it slips. - **Does NOT apply to external consumers**. Their `release.yml`'s `toolkit_ref` pins external tagged toolkit code that doesn't drift relative to their main; the check is toolkit-self-specific. ## Refs - **Closes**: [#124](https://git.frankenbit.de/frankenbit/release-toolkit/issues/124) - **Surveyor 116b/cf2c/fc7d**: the forward-thought + repeated calibration that filed and refined the tracker - **Empirical artifacts**: v0.8.0 + v0.9.0 OWNER-unbound failures; 10 in-cycle re-pins this session - **Related**: AGENTS.md §2 (mechanism-of-touch); #139 (paths-ignore for manifest-only commits) - structural backstop for self-bootstrap re-pin (closes #124) ### Fixed **Path-α self-cancel noise eliminated** ([#139](https://git.frankenbit.de/frankenbit/release-toolkit/issues/139)). ## The phenomenon (n=4 reproducible streak) Across v0.8.0, v0.9.0, v0.10.0, v0.10.2 cuts, the same pattern fired: 1. Cut workflow ran under path-α; release-create + manifest direct-push to main succeeded 2. The manifest push fired a new push:main event 3. Forgejo Actions cancelled the in-flight run in favor of the new one 4. Workflow exit reported "cancelled" instead of "success" — operator confused; substrate state was actually correct v0.10.0 even amplified the noise: operator's manual re-run hit the release-create 409 conflict (pre-#131 idempotency), making it look like a real failure. v0.10.2 hit the manifest non-fast-forward push (pre-#140 remote-aware pre-check). ## Primary fix: paths-ignore `docs/integration.md` quick-start template now shows: ```yaml on: push: branches: [main] paths-ignore: ['.release-toolkit-manifest.json'] ``` The toolkit's own consumer wrapper (`.forgejo/workflows/release.yml`) updated the same way. **The cut path's manifest commit is the only commit that touches ONLY the manifest** — so excluding `.release-toolkit-manifest.json` from the push trigger means the manifest commit doesn't fire a new workflow run. The race window goes away entirely. Substrate-state-care call-out: if a consumer customizes the `manifest_path` workflow input, the `paths-ignore` list must be updated to match. The integration.md docs surface this explicitly. ## Defense-in-depth: re-ordered stale-cleanup `_release.yml` cut path: the stale-rolling-PR cleanup step (#87) now runs **BEFORE** the manifest write+commit+push (was after). This means: - If for any reason paths-ignore doesn't catch the manifest commit (custom manifest_path, Forgejo runtime variance, etc.) - A cancellation arriving after the manifest push interrupts nothing load-bearing — the cleanup ran first The manifest push remains the last substantive substrate mutation in the cut path, so any cancellation hits idle workflow 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 concurrency: at the workflow level** — Forgejo Actions silently rejects top-level concurrency 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 the ordering of stale-cleanup relative to manifest push 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 (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 of which handled the re-run-after-cancel CONSEQUENCES; this fix eliminates the cancel CAUSE - **Open empirical sub-question from #141**: did the `git fetch --quiet origin main` in manifest-precheck.sh implicitly fix #139 on the v0.10.3 cut? — this PR makes that question moot; paths-ignore is the deterministic 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 (n=1 vs n=4) - **Surveyor 211f**: the validation-axis bilateral discipline that turned the partial-completion matrix's empirical-close-pending into verified - **Forgejo Actions constraint**: top-level `concurrency:` rejected per `tests/workflows.bats` (rules out the simpler concurrency-block fix) - **cut**: eliminate path-α self-cancel noise (closes #139)
forgejo-actions force-pushed release-prep/rolling from 595067fa30
All checks were successful
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 4s
manifest-check / check (pull_request) Successful in 0s
to 86b15541df 2026-06-27 00:10:48 +02:00
Compare
release-bot changed title from chore(release): v0.10.4 to chore(release): v0.11.0 2026-06-27 00:35:58 +02:00
forgejo-actions force-pushed release-prep/rolling from 86b15541df to 00038a500d
All checks were successful
check-self-bootstrap / check (push) Successful in 3s
release / decide + act (push) Successful in 5s
release / release (push) Successful in 0s
2026-06-27 00:35:59 +02:00
Compare
alex merged commit 00038a500d into main 2026-06-27 00:53:37 +02:00
alex deleted branch release-prep/rolling 2026-06-27 00:53:37 +02:00
Sign in to join this conversation.
No description provided.