fix(deploy): final-review fixes — NEXT.sh-absence regen, git-ignored fail-loud (rc5), bootstrap gitignore guard, doc polish

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho5EQCFTSvYamuRtVZpp2d
This commit is contained in:
Bastien Chanot 2026-06-27 18:11:14 +02:00
parent 91850eb63a
commit 79741e36e7
4 changed files with 38 additions and 12 deletions

View File

@ -35,7 +35,7 @@ Treated as settled corollaries: user executes out-of-band; a **new** `lib/deploy
PROCEDURE.md reference runbook — fixed shell + `# @delta:` annotated steps (edited IN-PLACE) PROCEDURE.md reference runbook — fixed shell + `# @delta:` annotated steps (edited IN-PLACE)
INCIDENTS.md DEP-NNN incident ledger: date, step, error verbatim, root cause, INCIDENTS.md DEP-NNN incident ledger: date, step, error verbatim, root cause,
fix (APPEND-ONLY; resolution = introducing commit, derive via git) fix (APPEND-ONLY; resolution = introducing commit, derive via git)
STATE deployed SHA + timestamp + outcome — the diff oracle (overwritten each deploy) STATE.json deployed SHA + timestamp + outcome — the diff oracle (overwritten each deploy)
NEXT.sh instantiated runbook — EPHEMERAL, not committed ; run STEP-BY-STEP NEXT.sh instantiated runbook — EPHEMERAL, not committed ; run STEP-BY-STEP
(checklist, manual # VERIFY: gates) — never `bash NEXT.sh` unattended (checklist, manual # VERIFY: gates) — never `bash NEXT.sh` unattended
@ -56,9 +56,9 @@ All three facts re-run live before writing this spec; observed output recorded,
**First-deploy detection = STATE-absent, deterministic. `describe` is off the detection path.** **First-deploy detection = STATE-absent, deterministic. `describe` is off the detection path.**
``` ```
[ -f .claude/deploy/STATE ] => exit 1 (absent = first deploy) <- THE detector [ -f .claude/deploy/STATE.json ] => exit 1 (absent = first deploy) <- THE detector
git describe --tags --match 'deploy/*' => fatal: No names found ; exit 128 <- only the reason NOT to use describe git describe --tags --match 'deploy/*' => fatal: No names found ; exit 128 <- only the reason NOT to use describe
[ -f .claude/deploy/STATE ] => exit 0 (present = delta path) [ -f .claude/deploy/STATE.json ] => exit 0 (present = delta path)
``` ```
**Delta = `git diff --name-only <STATE_SHA> HEAD`** (two explicit endpoints; no dots, so it cannot be misread as three-dot). **Delta = `git diff --name-only <STATE_SHA> HEAD`** (two explicit endpoints; no dots, so it cannot be misread as three-dot).

View File

@ -23,6 +23,8 @@ _out_of_scope() { # 0 = forbidden, 1 = in scope
_scope_violations() { local p; for p in "$@"; do _out_of_scope "$p" && printf '%s\n' "$p"; done; } _scope_violations() { local p; for p in "$@"; do _out_of_scope "$p" && printf '%s\n' "$p"; done; }
_ignored() { git check-ignore -q "$1"; } # rc 0 = ignored
_changed_only() { # echo passed files that actually have changes _changed_only() { # echo passed files that actually have changes
local p; for p in "$@"; do local p; for p in "$@"; do
[ -n "$(git status --porcelain -- "$p" 2>/dev/null)" ] && printf '%s\n' "$p"; done [ -n "$(git status --porcelain -- "$p" 2>/dev/null)" ] && printf '%s\n' "$p"; done
@ -52,6 +54,12 @@ case "$cmd" in
echo "deploy-commit: NOTHING committed. Caller must pass only .claude/deploy/ files."; } >&2 echo "deploy-commit: NOTHING committed. Caller must pass only .claude/deploy/ files."; } >&2
exit 4 exit 4
fi fi
mapfile -t ignored_paths < <(for p in "$@"; do _ignored "$p" && printf '%s\n' "$p"; done)
if [ "${#ignored_paths[@]}" -gt 0 ]; then
{ echo "deploy-commit: REFUSED — path(s) are git-ignored and will NOT persist; \`.claude/deploy/\` must be committable in this project:";
printf ' - %s\n' "${ignored_paths[@]}"; } >&2
exit 5
fi
_unsafe_state && { echo "deploy-commit: unsafe git state (detached/merge/rebase) — not committing" >&2; exit 3; } _unsafe_state && { echo "deploy-commit: unsafe git state (detached/merge/rebase) — not committing" >&2; exit 3; }
mapfile -t changed < <(_changed_only "$@") mapfile -t changed < <(_changed_only "$@")
[ "${#changed[@]}" -gt 0 ] || exit 1 [ "${#changed[@]}" -gt 0 ] || exit 1

View File

@ -45,4 +45,9 @@ check T7-three-files "$(git -C "$d" show --name-only --format= HEAD | grep -c de
d=$(mkrepo); printf 'b\n' >"$d/src.txt" d=$(mkrepo); printf 'b\n' >"$d/src.txt"
( cd "$d" && bash "$H" pending src.txt ) >/dev/null 2>&1; check T8-pending-out-of-scope-rc "$?" 4 ( cd "$d" && bash "$H" pending src.txt ) >/dev/null 2>&1; check T8-pending-out-of-scope-rc "$?" 4
d=$(mkrepo); printf '.claude/\n' >"$d/.gitignore"
printf 'run\n' >"$d/.claude/deploy/PROCEDURE.md"
( cd "$d" && bash "$H" commit "docs(deploy): t" .claude/deploy/PROCEDURE.md ) >/dev/null 2>&1
check T9-ignored-rc "$?" 5
printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ] printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ]

View File

@ -121,9 +121,10 @@ Read `.claude/deploy/PENDING.json` **first** (it is the only memory between runs
"A deploy started `<started_at>` is awaiting your report (target `<target_sha>`)." "A deploy started `<started_at>` is awaiting your report (target `<target_sha>`)."
**Do not** recompute the delta, re-read HEAD, or re-instantiate from scratch — **Do not** recompute the delta, re-read HEAD, or re-instantiate from scratch —
the bridge is authoritative. the bridge is authoritative.
- *Staleness guard:* if `runbook_rev` ≠ the live runbook commit - *Staleness guard:* if `NEXT.sh` is absent **OR** `runbook_rev` ≠ the live
(`git log -1 --format=%h -- .claude/deploy/PROCEDURE.md`), the on-disk runbook commit (`git log -1 --format=%H -- .claude/deploy/PROCEDURE.md`),
`NEXT.sh` is stale (a patch landed) — regenerate it from `step_reached` the on-disk `NEXT.sh` is stale or missing (a patch landed, or a cold
resume without regeneration) — regenerate it from `step_reached`
(STEP 2's expansion) before reacting. (STEP 2's expansion) before reacting.
- **`PENDING.json` absent + `PROCEDURE.md` absent → BOOTSTRAP.** No runbook yet: - **`PENDING.json` absent + `PROCEDURE.md` absent → BOOTSTRAP.** No runbook yet:
interview the project and scaffold an annotated `PROCEDURE.md` (or adopt one interview the project and scaffold an annotated `PROCEDURE.md` (or adopt one
@ -164,7 +165,7 @@ Author a runbook, seed the incident ledger, commit both, then proceed to STEP 1.
- Migration steps (`psql -f`, `migrate up`, `supabase migration`) → - Migration steps (`psql -f`, `migrate up`, `supabase migration`) →
`# @delta:migrations glob=supabase/migrations/*.sql:list` `# @delta:migrations glob=supabase/migrations/*.sql:list`
- Build/restart steps (`docker compose`, `make build`, image push) → - Build/restart steps (`docker compose`, `make build`, image push) →
`# @delta:rebuild when=docker-compose*.yml,Dockerfile,*.dockerfile` `# @delta:rebuild when=docker-compose*.yml,Dockerfile,Dockerfile.*`
- Dep-install steps (`npm ci`, `pip install -r`, `bundle install`) → - Dep-install steps (`npm ci`, `pip install -r`, `bundle install`) →
`# @delta:deps when=package.json,*lock*,requirements.txt,pyproject.toml` `# @delta:deps when=package.json,*lock*,requirements.txt,pyproject.toml`
4. Present the annotated draft; invite corrections before the gate. 4. Present the annotated draft; invite corrections before the gate.
@ -217,14 +218,24 @@ Present the full draft `PROCEDURE.md`.
1. Write `.claude/deploy/PROCEDURE.md` (Write tool — the approved draft). 1. Write `.claude/deploy/PROCEDURE.md` (Write tool — the approved draft).
2. Seed `.claude/deploy/INCIDENTS.md` from `templates/deploy/INCIDENTS.md` (Write tool). 2. Seed `.claude/deploy/INCIDENTS.md` from `templates/deploy/INCIDENTS.md` (Write tool).
3. Commit both via the allowlist helper: 3. Ensure the target project's `.gitignore` contains `.claude/deploy/NEXT.sh` and
`.claude/deploy/PENDING.json` (append both if missing — these are the transient
artifacts that must not be committed).
4. Check that `.claude/deploy/` is NOT git-ignored: `git check-ignore -q .claude/deploy/PROCEDURE.md`
(rc 0 = ignored). If ignored — e.g. the project has `.claude/` in its `.gitignore` wholesale —
**ABORT bootstrap**: warn the user that the runbook/oracle/ledger cannot be committed,
and tell them to un-ignore `.claude/deploy/` (e.g. add `!.claude/deploy/` after the
`.claude/` rule). Do NOT commit anything further.
5. Commit via the allowlist helper:
```bash ```bash
bash lib/deploy-commit.sh commit \ bash lib/deploy-commit.sh commit \
"feat(deploy): bootstrap runbook" \ "feat(deploy): bootstrap runbook" \
.claude/deploy/PROCEDURE.md .claude/deploy/INCIDENTS.md .claude/deploy/PROCEDURE.md .claude/deploy/INCIDENTS.md
``` ```
Return codes: **0** committed · **1** no-op (investigate — both files should be new) · Return codes: **0** committed · **1** no-op (investigate — both files should be new) ·
**3** unsafe git state (STOP, tell user) · **4** out-of-scope path · **2** usage error. **3** unsafe git state (STOP, tell user) · **4** out-of-scope path ·
**5** a passed path is git-ignored (won't persist) — STOP, fix the target's `.gitignore` ·
**2** usage error OR not a git repo.
**On rc=0: continue to STEP 1.** `STATE.json` absent → first deploy → **On rc=0: continue to STEP 1.** `STATE.json` absent → first deploy →
STEP 1 sets `base_sha: null` and the full runbook fires (every fixed step and STEP 1 sets `base_sha: null` and the full runbook fires (every fixed step and
@ -275,7 +286,7 @@ Set the base, compute the changed-file list, capture the target.
{ "base_sha": "<STEP 1 base>", "target_sha": "<STEP 1 target>", { "base_sha": "<STEP 1 base>", "target_sha": "<STEP 1 target>",
"delta": [<STEP 1 file list>], "step_reached": "awaiting-user", "delta": [<STEP 1 file list>], "step_reached": "awaiting-user",
"started_at": "<now, ISO-8601>", "started_at": "<now, ISO-8601>",
"runbook_rev": "<git log -1 --format=%h -- .claude/deploy/PROCEDURE.md>" } "runbook_rev": "<git log -1 --format=%H -- .claude/deploy/PROCEDURE.md>" }
``` ```
**Then HAND BACK** (AskUserQuestion): *"Run NEXT.sh step by step against prod. **Then HAND BACK** (AskUserQuestion): *"Run NEXT.sh step by step against prod.
Report back: **Deployed OK** / **Failed at step X: <err>** / **Not yet**."* Then Report back: **Deployed OK** / **Failed at step X: <err>** / **Not yet**."* Then
@ -319,7 +330,9 @@ bash lib/deploy-commit.sh commit \
Return codes: **0** committed (short-hash on stdout) · **1** nothing staged — you Return codes: **0** committed (short-hash on stdout) · **1** nothing staged — you
wrote neither file · **3** unsafe git state (detached/merge/rebase — STOP, tell wrote neither file · **3** unsafe git state (detached/merge/rebase — STOP, tell
the user) · **4** out-of-scope path (you passed a non-`.claude/deploy/` path — fix the user) · **4** out-of-scope path (you passed a non-`.claude/deploy/` path — fix
the call) · **2** usage error. The helper commits whatever subset actually changed; the call) · **5** a passed path is git-ignored (won't persist) — STOP, fix the
target's `.gitignore` · **2** usage error OR not a git repo. The helper commits
whatever subset actually changed;
patch+incident coupling is **Claude-discipline, not helper-enforced**. patch+incident coupling is **Claude-discipline, not helper-enforced**.
**This commit IS the resolution** — the commit that introduces `DEP-NNN` is its **This commit IS the resolution** — the commit that introduces `DEP-NNN` is its
@ -327,7 +340,7 @@ fix (patch + incident committed atomically). Recover later via
`git log -S '<DEP-NNN>' -- .claude/deploy/INCIDENTS.md`. No backfill needed. `git log -S '<DEP-NNN>' -- .claude/deploy/INCIDENTS.md`. No backfill needed.
Then: Then:
1. Bump `PENDING.json.runbook_rev` to that commit's sha; keep `step_reached` = `X`. 1. Bump `PENDING.json.runbook_rev` to `git rev-parse HEAD` (full sha — not the helper's short-hash stdout); keep `step_reached` = `X`.
2. **Regenerate `NEXT.sh` from `step_reached` against the PATCHED runbook** 2. **Regenerate `NEXT.sh` from `step_reached` against the PATCHED runbook**
(steps X…end — X+1…end never ran). This is NOT replaying one step: the bumped (steps X…end — X+1…end never ran). This is NOT replaying one step: the bumped
`runbook_rev` is exactly the staleness trigger — runbook changed ⇒ prior `runbook_rev` is exactly the staleness trigger — runbook changed ⇒ prior