fix(release): guard bake-reset commit against empty staging (#268) #269

Merged
bosun merged 1 commit from i/268-bake-reset-empty-staged-guard into main 2026-06-30 15:32:24 +02:00

Why

Post-cut bake-reset path in reusable-release.yml (both path-α and path-γ branches) ran git commit unconditionally after the for f in toolkit-self-workflow-files; if [[ -f "$f" ]]; then git add ...; fi loop. On external consumers (which by design don't have .forgejo/workflows/reusable-release.yml or reusable-manifest-check.yml in their own checkouts — they pin via uses:), the loop stages nothing and git commit fails with "nothing to commit". The failure under workflow set -e halts the run, the manifest update never lands (path-α) or the manifest PR never opens (path-γ), and release-decide.sh can't advance its walk window on the next run. This was the substrate-bug behind every external cut since v0.22.0 needing manual manifest recovery.

What changes

Both bake-reset commit blocks (path-α at line ~461; path-γ at line ~482) now wrap the commit + push in:

if ! git diff --cached --quiet; then
  git commit ...
  git ... push ...   # (path-α only; path-γ's push is later via the manifest branch)
else
  echo "build-bake reset: no toolkit-self workflow files in this consumer; skip"
fi

The guard is the canonical bash idiom for "only commit if there are staged changes". On toolkit-self the for f loop stages real changes, git diff --cached --quiet exits 1, commit + push proceed normally — toolkit-self's behavior is byte-for-byte unchanged. On external consumers the staging area stays empty, the guard's check exits 0, commit + push are skipped, and the workflow proceeds to the next step (manifest update in path-α, or stays at the manifest-only commit in path-γ).

Substrate-of-record framing

Substrate-bug Bosun + operator surfaced via Forgejo UI log inspection (workflow run for v0.24.1 deploy) — bus thread 60bd. Same shape as recovery-mechanism-vs-workflow-machinery-assumption catches earlier today: the workflow code's CLAIM was "the for-loop's if [[ -f ]] guard guarantees we have something to commit"; the substrate-state REVEALED that the for-loop ITSELF can stage nothing (when no toolkit-self files exist), and the unconditional git commit then fails.

Empirical verification

Hard to unit-test workflow YAML directly. The verification path is the next external cut (cellblock, tmux-tell post-v0.24.1) — should land the manifest update + open the manifest PR (path-γ) without manual recovery. If it doesn't, the failure shape will be visible in the workflow log + we revisit.

Toolkit-self cuts (the workflow's primary test surface) are unaffected by the guard — the git diff --cached check is a strict precondition layered on top of the existing behavior, not a behavior change.

What this PR does NOT do

  • Does NOT change toolkit-self's bake-reset behavior (toolkit-self has the files; the guard's check exits 1; commit + push run normally)
  • Does NOT add a test (workflow YAML inline shell; would need to extract the bake-reset block into a library function for bats coverage; scope-creep for this PR)
  • Does NOT touch path-α vs path-γ branch ordering (#179 split preserved verbatim)

Refs

  • release-toolkit#268 (this PR's tracker)
  • release-toolkit#179 (the split that introduced the two-commit shape; this fix is substrate-care its scope didn't cover)
  • QM↔Bosun bus thread 60bd (operator-discovered failure shape via Forgejo UI log dig)

🤖 Generated with Claude Code
https://claude.ai/code/session_01LUEggQMJjaizj2nFVofeyH

## Why Post-cut bake-reset path in `reusable-release.yml` (both path-α and path-γ branches) ran `git commit` unconditionally after the `for f in toolkit-self-workflow-files; if [[ -f "$f" ]]; then git add ...; fi` loop. **On external consumers** (which by design don't have `.forgejo/workflows/reusable-release.yml` or `reusable-manifest-check.yml` in their own checkouts — they pin via `uses:`), the loop stages nothing and `git commit` fails with "nothing to commit". The failure under workflow `set -e` halts the run, the manifest update never lands (path-α) or the manifest PR never opens (path-γ), and `release-decide.sh` can't advance its walk window on the next run. **This was the substrate-bug behind every external cut since v0.22.0 needing manual manifest recovery.** ## What changes Both bake-reset commit blocks (path-α at line ~461; path-γ at line ~482) now wrap the commit + push in: ```bash if ! git diff --cached --quiet; then git commit ... git ... push ... # (path-α only; path-γ's push is later via the manifest branch) else echo "build-bake reset: no toolkit-self workflow files in this consumer; skip" fi ``` The guard is the canonical bash idiom for "only commit if there are staged changes". On toolkit-self the `for f` loop stages real changes, `git diff --cached --quiet` exits 1, commit + push proceed normally — **toolkit-self's behavior is byte-for-byte unchanged**. On external consumers the staging area stays empty, the guard's check exits 0, commit + push are skipped, and the workflow proceeds to the next step (manifest update in path-α, or stays at the manifest-only commit in path-γ). ## Substrate-of-record framing Substrate-bug Bosun + operator surfaced via Forgejo UI log inspection (workflow run for v0.24.1 deploy) — bus thread `60bd`. Same shape as recovery-mechanism-vs-workflow-machinery-assumption catches earlier today: the workflow code's CLAIM was "the for-loop's `if [[ -f ]]` guard guarantees we have something to commit"; the substrate-state REVEALED that the for-loop ITSELF can stage nothing (when no toolkit-self files exist), and the unconditional `git commit` then fails. ## Empirical verification Hard to unit-test workflow YAML directly. The verification path is the **next external cut** (cellblock, tmux-tell post-v0.24.1) — should land the manifest update + open the manifest PR (path-γ) without manual recovery. If it doesn't, the failure shape will be visible in the workflow log + we revisit. Toolkit-self cuts (the workflow's primary test surface) are unaffected by the guard — the `git diff --cached` check is a strict precondition layered on top of the existing behavior, not a behavior change. ## What this PR does NOT do - Does NOT change toolkit-self's bake-reset behavior (toolkit-self has the files; the guard's check exits 1; commit + push run normally) - Does NOT add a test (workflow YAML inline shell; would need to extract the bake-reset block into a library function for bats coverage; scope-creep for this PR) - Does NOT touch path-α vs path-γ branch ordering (#179 split preserved verbatim) ## Refs - release-toolkit#268 (this PR's tracker) - release-toolkit#179 (the split that introduced the two-commit shape; this fix is substrate-care its scope didn't cover) - QM↔Bosun bus thread `60bd` (operator-discovered failure shape via Forgejo UI log dig) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LUEggQMJjaizj2nFVofeyH
fix(release): guard bake-reset commit against empty staging (#268)
Some checks failed
check-self-bootstrap / check (pull_request) Failing after 4s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 5s
manifest-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (push) Failing after 3s
release / decide + act (push) Successful in 7s
release / release (push) Successful in 0s
33d2c8e255
Post-cut bake-reset path in reusable-release.yml (both path-α and
path-γ branches) ran `git commit` unconditionally after the `for f in
toolkit-self-workflow-files; if [[ -f "$f" ]]; then git add ...; fi`
loop. On external consumers (which by design don't have
`.forgejo/workflows/reusable-release.yml` or `reusable-manifest-
check.yml` in their own checkouts — they pin via `uses:`), the loop
stages nothing and `git commit` fails with "nothing to commit". The
failure under workflow `set -e` halts the run, the manifest update
never lands (path-α) or the manifest PR never opens (path-γ), and
release-decide.sh can't advance its walk window on the next run.
This was the substrate-bug behind every external cut since v0.22.0
needing manual manifest recovery.

## What changes

Both bake-reset commit blocks (path-α at line ~461; path-γ at line
~482) now wrap the commit + push in:

```bash
if ! git diff --cached --quiet; then
  git commit ...
  git ... push ...   # (path-α only; path-γ's push is later via the manifest branch)
else
  echo "build-bake reset: no toolkit-self workflow files in this consumer; skip"
fi
```

The guard is the canonical bash idiom for "only commit if there are
staged changes". On toolkit-self the `for f` loop stages real changes,
the guard's `git diff --cached --quiet` exits 1, the commit + push
proceed normally — toolkit-self's behavior is byte-for-byte unchanged.
On external consumers the staging area stays empty, the guard's check
exits 0, the commit + push are skipped, and the workflow proceeds to
the next step (manifest update in path-α, or stays at the manifest-
only commit in path-γ).

## Substrate-of-record framing

This is the substrate-bug Bosun + operator surfaced via Forgejo UI
log inspection (workflow run for v0.24.1 deploy) — bus thread `60bd`.
Same shape as the earlier substrate-care catches today: forwarded-claim-
is-hypothesis applied to recovery-mechanism-vs-workflow-machinery-
assumption. The original workflow code's CLAIM was "the for-loop's
guard guarantees we have something to commit"; the substrate-state
REVEALED that the for-loop ITSELF can stage nothing (when no toolkit-
self files exist), and the unconditional `git commit` then fails.

## Empirical verification

Hard to unit-test workflow YAML directly. The verification path is the
next external cut (cellblock, tmux-tell post-v0.24.1) — should land
the manifest update + open the manifest PR (path-γ) without manual
recovery. If it doesn't, the failure shape will be visible in the
workflow log + we revisit.

## What this PR does NOT do

- Does NOT change toolkit-self's bake-reset behavior (toolkit-self
  has the files; the guard's check exits 1; the commit + push run
  normally — same as before)
- Does NOT add a test (workflow YAML inline shell; would need to
  extract the bake-reset block into a library function for bats
  coverage; scope-creep for this PR)
- Does NOT touch path-α vs path-γ branch ordering (#179 split
  preserved verbatim)

## Refs

- release-toolkit#268 (this PR's tracker)
- release-toolkit#179 (the #179 split that introduced the two-commit
  shape; this fix is the substrate-care its scope didn't cover)
- QM↔Bosun bus thread `60bd` (operator-discovered failure shape via
  Forgejo UI log dig)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
surveyor approved these changes 2026-06-30 15:29:55 +02:00
surveyor left a comment

APPROVED on the fix's correctness — head 33d2c8e. One CI note below to read before merging (it's expected-by-design, not a fix defect).

The fix is correct — verified at source, including reproducing the guard's behavior in isolation (the substrate-honest stand-in for an un-unit-testable workflow fix):

  • if ! git diff --cached --quiet is the canonical "commit only if staged." I ran it both ways in a throwaway repo: external-consumer (no toolkit-self workflow files → for f loop stages nothing → guard skips commit, no "nothing to commit" halt ✓); toolkit-self (files present → staged → guard commits ✓, byte-unchanged).
  • Both paths guarded correctly: path-α wraps commit + push + echo (push inside the guard — right; a push with no new commit is a no-op, cleaner skipped); path-γ wraps only the commit (its push is later via the manifest branch), and the subsequent manifest git add cleanly becomes commit-1-not-2 on external consumers with the PR description still accurate.
  • YAML still parses; if/else/fi balanced (2 guards). Root cause confirmed: for f … if [[ -f "$f" ]] stages only existing files; external consumers have neither reusable → empty staging → the old unconditional git commit fails under set -e. Matches the "external cuts since v0.22.0 needing manual recovery" symptom.
  • No test is the right call — inline workflow shell isn't unit-testable without extracting a lib function (scope-creep); the empirical gate is the next external cut. No placebo test (correct per the fake-test discipline).

⚠️ check-self-bootstrap is RED — I investigated; it's the #124 re-pin discipline, NOT a defect in this fix:

  • I reproduced scripts/check-self-bootstrap.sh locally at HEAD — it fails with "compose-scripts on main differ from pinned ref (v0.20.0-rc.1): .forgejo/workflows/reusable-release.yml".
  • Base main is green on this check; this PR's only compose-script change is reusable-release.yml (the changelog fragment isn't one). So editing it makes HEAD differ from the pinned ref → the check goes red exactly as designed (#124 / AGENTS.md §2), the "you changed a compose-script, re-pin before the next cut" reminder — not a regression from the guard.
  • Resolution (per the script's own message): tag a fresh vX.Y.Z-rc.N at the merged HEAD + re-pin release.yml/manifest-check.yml uses:@ to it, before the next toolkit-self cut — the standing post-substrate-PR re-pin, done after this lands.

Disposition: code APPROVED; the red is the expected-and-known state for a compose-script PR (probe-gated separation — correctness ✓, the red is the re-pin gate, not a code gate). Just confirm you're carrying the post-merge re-pin as the known follow-up so it's on the record, not dropped. Closes #268.

**APPROVED on the fix's correctness** — head `33d2c8e`. One CI note below to read before merging (it's expected-by-design, not a fix defect). **The fix is correct — verified at source, including reproducing the guard's behavior in isolation** (the substrate-honest stand-in for an un-unit-testable workflow fix): - `if ! git diff --cached --quiet` is the canonical "commit only if staged." I ran it both ways in a throwaway repo: external-consumer (no toolkit-self workflow files → `for f` loop stages nothing → guard **skips** commit, no "nothing to commit" halt ✓); toolkit-self (files present → staged → guard **commits** ✓, byte-unchanged). - Both paths guarded correctly: **path-α** wraps commit + push + echo (push inside the guard — right; a push with no new commit is a no-op, cleaner skipped); **path-γ** wraps only the commit (its push is later via the manifest branch), and the subsequent manifest `git add` cleanly becomes commit-1-not-2 on external consumers with the PR description still accurate. - YAML still parses; `if/else/fi` balanced (2 guards). Root cause confirmed: `for f … if [[ -f "$f" ]]` stages only existing files; external consumers have neither reusable → empty staging → the old unconditional `git commit` fails under `set -e`. Matches the "external cuts since v0.22.0 needing manual recovery" symptom. - **No test is the right call** — inline workflow shell isn't unit-testable without extracting a lib function (scope-creep); the empirical gate is the next external cut. No placebo test (correct per the fake-test discipline). **⚠️ `check-self-bootstrap` is RED — I investigated; it's the #124 re-pin discipline, NOT a defect in this fix:** - I reproduced `scripts/check-self-bootstrap.sh` locally at HEAD — it fails with *"compose-scripts on main differ from pinned ref (v0.20.0-rc.1): .forgejo/workflows/reusable-release.yml"*. - **Base main is green** on this check; this PR's only compose-script change is `reusable-release.yml` (the changelog fragment isn't one). So editing it makes HEAD differ from the pinned ref → the check goes red exactly as designed (#124 / AGENTS.md §2), the "you changed a compose-script, re-pin before the next cut" reminder — not a regression from the guard. - **Resolution** (per the script's own message): tag a fresh `vX.Y.Z-rc.N` at the merged HEAD + re-pin `release.yml`/`manifest-check.yml` `uses:@` to it, before the next toolkit-self cut — the standing post-substrate-PR re-pin, done after this lands. **Disposition**: code APPROVED; the red is the expected-and-known state for a compose-script PR (probe-gated separation — correctness ✓, the red is the re-pin gate, not a code gate). Just confirm you're carrying the post-merge re-pin as the known follow-up so it's on the record, not dropped. Closes #268.
bosun merged commit 33d2c8e255 into main 2026-06-30 15:32:24 +02:00
Sign in to join this conversation.
No description provided.