Consolidate docs/decisions.md (1923 -> 1028) by extracting four cohesive topic clusters into docs/decisions/ (optimistic-concurrency, api-auth-security, release-ci-governance, spa-modularization) — content relocated verbatim (lossless; all rationale + reversals preserved), main Index rebuilt to reference the topic files plus the remaining in-file entries, docs/README.md points back at the decisions Index. Add the v26.9.0 row to the ci-cd.md version table. refs #340 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
18 KiB
Release, CI & merge-governance decisions (#303, #311, #314, #315, #335)
Why the merge/release process is enforced by hooks and CI gates rather than prose: the #303
methodology-hardening waves (state-derived merge-consent, API-contract CI gate, append-only
decisions log, review-verdict gate), formatting-as-you-touch + rebase discipline, the
auto-grant fix, migration rehearsal on a prod-DB copy, and release promotion. Rationale
relocated from the append-only docs/decisions.md at the v26.9.0 consolidation; operational
detail cross-links to docs/ci-cd.md and CLAUDE.md → Task Completion Protocol.
Issue trail: #303 (H4/H5 api-docs gate, H6 Done-when, H9/H3 append-only + root-png, H10 review-verdict), #311 (H11 formatting/rebase), #314 (merge-gate auto-grant), #315 (migration rehearsal), #335 (release promotion). The whole hook program's throughline: make each process rule a derivation/hook, not prose to remember (#303 methodology review).
Contents
- 2026-07-12 — Blocking CI gate for API-contract artifacts (#303 H4/H5)
- 2026-07-12 — Merge-consent derived from state via a
## Done-whenissue checklist (#303 H6) - 2026-07-12 — decisions.md is append-only, enforced; root-screenshot guard (#303 H9/H3)
- 2026-07-12 — Review-verdict merge-gate: latest commit must be reviewed (#303 H10)
- 2026-07-12 — Formatting-as-you-touch, enforced; rebase-not-merge for PR branches (#311 H11 + format CI)
- 2026-07-12 — Merge-consent gate auto-grants when satisfied (no redundant prompt); state IS the consent (#314)
- 2026-07-12 — Release path rehearses migrations on a prod-DB copy before promoting (#315)
- 2026-07-13 — Release promotion: floating
:prod, exact-image scan before manual deploy (#335)
2026-07-12 — Blocking CI gate for API-contract artifacts (#303 H4/H5)
A PR whose diff touches ErsatzTV/Controllers/Api/** or ErsatzTV.Core/Api/** must ship the
regenerated OpenAPI artifacts in that same diff, enforced by a blocking api-docs CI job. It rebuilds
ErsatzTV/wwwroot/openapi/v1.json, web/src/api/generated/v1.d.ts, and docs/endpoint-index.md from
source and fails on any drift. This mechanizes the previously prose-only "docs-update in the same PR"
rule for the API contract (the docs-reminder job stays a non-blocking nudge for the route-parity doc).
Path-gated inside the job (not via a top-level if:) so it always reports a status on every PR — API-free
PRs skip the expensive regen and pass trivially, so it is safe as a required check. Rationale: generation
is deterministic from a fresh build (verified — a clean checkout reproduces the committed spec exactly,
including the 244 auth security/401 blocks), so CI can trust regenerate-and-diff. The one caveat is
local-only: update-openapi.sh runs dotnet-getdocument against the already-built assembly, so a stale
bin/ silently yields a stale spec — always dotnet build first (see api-conventions.md §5). CI is
immune (no bin/ on a fresh checkout).
2026-07-12 — Merge-consent derived from state via a ## Done-when issue checklist (#303 H6)
An issue's ## Done-when checklist (in the issue body) is the machine-readable source of truth for whether
its PR may merge; consent is derived, not asserted. Rationale: DONE/OPEN status used to live in
append-only prose that lags live Gitea state (the queue-drift #303 fixes) — so the completion gate moves out
of memory and into a checklist two hooks read. Convention: the issue body carries a ## Done-when section
(always an "adversarial review passed" box, plus per-issue criteria); a merge is allowed only when the PR's CI
is green and every box on the linked issue (fixes #N) is ticked.
Enforcement (both fail safe, never a silent pass):
pretooluse-merge-consent.sh— Claude PreToolUse onmcp__gitea__pull_request_writemerge: deny on an unticked box or non-green CI; allow when both satisfied; ask (human prompt) when state isn't derivable (no linked issue, no## Done-when, no creds, Gitea unreachable). Docs-only PRs exempt..husky/pre-push→prepush-donewhen.sh— backstop for a directgit push origin main; fail-open (a git hook has no "ask"), blocks only on a positively-proven unticked box.
Both authenticate to Gitea from env only (ETV_GITEA_BASICAUTH / ETV_GITEA_TOKEN, ETV_GITEA_URL) — no
creds committed; without them the gate degrades to today's manual confirmation. Rollout is non-breaking: until
issues adopt ## Done-when, the merge hook simply asks rather than auto-allowing. See CLAUDE.md → Task
Completion Protocol. (H6 lives with H1/H2/H8 in .claude/settings.json; H7 worktree-owner guard is its
sibling Wave-2 hook.)
2026-07-12 — decisions.md is append-only, enforced; root-screenshot guard (#303 H9/H3)
This log is append-only by construction, not just by convention. A commit or PR that deletes or
modifies an existing line of docs/decisions.md is blocked — by the Husky commit-msg hook
(.claude/hooks/decisions-guard.sh staged) locally and the blocking decisions-guard CI job (same
script, range mode) on PRs. Shared detection, deliberately different granularity: the Husky hook
gates each commit (its own message must carry the token); CI gates the PR-wide net diff
(token in any commit of the range suffices), so the local hook is the stricter primary gate and CI the
push/bypass backstop. Insertions anywhere are always allowed, so a normal new entry (TOC line
near the top + a block appended at the bottom, both pure insertions) passes untouched. Detection is
git diff --numstat deleted-count > 0, which is robust to markdown - list markers (a byte-level -
prefix would false-match). The block is lifted only by the literal [decisions-edit] token in the
commit message, reserved for two cases: fixing a factual error, and superseding a reversed decision
(add the new entry, prepend a > **Superseded …** banner to the old one, tag its Index line
(superseded) — keep the old rationale, never silently rewrite). Consolidation of superseded
entries is a release-checklist step (docs/ci-cd.md → Versioning & releases), backstopped by a
non-blocking 1800-line size floor in the decisions-guard job (the read-cost point past which the
log no longer fits one default agent Read), so append-only doesn't accrete contradictory or
unreadably-large history between releases (Timothy's call, 2026-07-12: mark-and-keep on reversal,
consolidate at each milestone, size-floor backstop).
Companion guard H3: the Husky pre-commit hook refuses a staged root-level *.png (a
review/debug screenshot dropped at the repo root) — belt-and-suspenders with the .gitignore rule, so
a forced git add -f still can't land one. Nested *.png (real assets) are unaffected. Rationale for
both: the methodology review (#303) — make the process rules derivations/hooks, not prose to remember.
2026-07-12 — Review-verdict merge-gate: latest commit must be reviewed (#303 H10)
A PR may not merge until a Review-verdict: comment on it references the PR's CURRENT head sha —
so the latest commit is proven-reviewed, not a stale earlier diff. This mechanizes the ersatztv#242
lesson ("re-review the fix commit, not just the initial PR diff": a review of an earlier revision does
not license merging a head that carries un-reviewed follow-up commits). It folds into the existing H6
pretooluse-merge-consent.sh as condition (c), reusing its PR fetch, docs-only exemption, and
Gitea-auth-from-env (no second hook → no detection drift, per the #303 methodology review).
Convention: after reviewing a PR (or its latest fix commit), post a PR comment (issue-style, not a
Gitea formal-review body — the gate reads issues/{pr}/comments) whose line starts with the marker:
Review-verdict: <MERGEABLE|APPROVED|BLOCKED|NOT-MERGEABLE> @ <head-sha> (short ≥7-char or full sha).
The gate counts a line as a verdict only when the marker is at line-start (after optional indent) —
a comment that merely quotes the template mid-sentence (an instruction "please post: Review-verdict:
MERGEABLE @ …", or the gate's own suggestion text echoed back) does not self-approve the merge
(adversarial re-review false-open, folded pre-merge). It then classifies each verdict line by the sha
in its @ <sha> field, matched to the head by git short-sha prefix semantics (head begins with
the token, token ≥7 chars) — NOT a loose substring test, so an older sha that merely contains the head
prefix, or the head prefix appearing in an unrelated URL on the line, does not count:
- a MERGEABLE/APPROVED/LGTM verdict whose
@ <sha>is the current head → allow; - a negative verdict (BLOCKED/NOT-MERGEABLE) on the head → deny, and it wins over a positive
one on the same head (a later BLOCKED retracts an earlier MERGEABLE; to retract, re-review head and
post BLOCKED @ head). Staleness is symmetric on purpose: a negative for an older commit is stale
exactly like a positive for an older commit, and does NOT override a fresh head-positive — otherwise a
pre-fix
BLOCKED @ oldshawould block forever even after the fix changes the sha and earns a freshMERGEABLE @ head(the normal flow). So a genuine block must reference head, per the convention; - verdict comment(s) exist but reference only older commits → deny — the stale-review case #242 targets;
- a
Review-verdict:marker with no@ <sha>at all → ask (a lazy/quoted marker; not mislabelled as stale); - no
Review-verdict:comment at all → ask (graceful adoption, mirrors H6's "no Done-when → ask": surface, don't hard-block a PR that hasn't adopted the convention yet); - comments unfetchable / head sha unresolvable → ask.
Scope: the Claude PreToolUse gate on the Gitea merge tool only. A direct git push origin main has no
PR comments to check, so the .husky/pre-push backstop is not extended for H10 (the merge tool is the
real merge path; docs-only PRs remain exempt via H6's file-set exemption). Rationale, as with the whole
Wave-1/2/3 hook set: make the process rule a derivation/hook, not prose to remember (#303).
2026-07-12 — Formatting-as-you-touch, enforced; rebase-not-merge for PR branches (#311 H11 + format CI)
Two coupled process decisions, prompted when a stale docs branch merged main in, dragged ~17
legacy-BOM .cs files it never touched into the merge commit, and the pre-commit dotnet format
hook then blocked on code that wasn't the author's (#309 session; the BOM backlog is #310).
1. Formatting-as-you-touch is the standing rule, and it is now enforced (not just prose). ~2500
of ~3900 .cs files carry a legacy UTF-8 BOM that violates .editorconfig's charset=utf-8. We do
not mass-reformat (a repo-wide normalization stays an unmade, separate decision). Instead, a file
you modify for other work must be normalized (dotnet format, incl. BOM strip) in that same PR.
Enforcement — previously only the --no-verify-bypassable pre-commit hook, which is how #269 landed 17
BOM files (CI never checked charset):
- a blocking
formatCI job runsdotnet format --verify-no-changesscoped to the PR's changed.cs(vs the merge-base) — so it demands conformance only of files the PR touched, never the untouched legacy 2500; a.cs-free PR skips the expensive steps and passes (always reports a status, safe as a required check). This closes the "CI never verifies charset/format" gap. docs/contributing.md§7 documents the rule.
2. Keep a PR branch current by REBASING on origin/main, never merging main in (H11). A merge
commit pulls in every file main changed — including files the author never touched — which then trip
the format hook/CI on code that isn't theirs; rebasing keeps the diff to exactly what changed.
Enforced by .claude/hooks/prepush-rebase-check.sh wired into .husky/pre-push: a push from a branch
that is behind origin/main (origin/main not an ancestor of HEAD) is blocked with
git rebase origin/main guidance. Fail-open (offline / no origin/main / not a repo → allow, since a
git hook has no "ask"); deliberate escape ETV_SKIP_REBASE_CHECK=1. This supersedes the old lore
guidance to "merge main into your PR branch." (After a rebase that conflicts in generated artifacts —
v1.json/v1.d.ts/endpoint-index — regenerate, don't hand-resolve; npm run check:api guards.)
Rationale, as with the whole hook program: make the process rule a derivation/hook, not prose to remember (#303 methodology review). Tracked: #311; sibling #312 (H12 issue-qualification audit).
2026-07-12 — Merge-consent gate auto-grants when satisfied (no redundant prompt); state IS the consent (#314)
Completes the #303 H6/H10 intent — derive merge-consent from state — which the original hook only
half-delivered. The rule the user set: merge permission is auto-granted for the session when the
linked issue's ## Done-when boxes are all ticked, a fresh positive Review-verdict references the
current head, and CI is green — no separate confirmation, conversational or mechanical.
Root cause of the bug this fixes: pretooluse-merge-consent.sh's satisfied path did a bare
exit 0. A PreToolUse hook that exits 0 with no JSON does not auto-approve — it only declines to
block, so control falls through to the normal permission system and the raw MCP permission prompt
still fires (the merge tool isn't allow-listed). So the gate only ever added a deny/ask net; it never
removed the baseline prompt on the happy path. Net effect for the operator: a ready-to-merge PR was
confirmed twice — once conversationally (the per-session merge-consent norm) and again by a redundant
mechanical prompt the gate was supposed to have subsumed.
Fix: ONLY the genuinely-satisfied merge path (a+b+c all true) now emits
{"hookSpecificOutput":{"permissionDecision":"allow", ...}} (a new grant decision), which actually
suppresses the prompt. Deny (unticked/red/negative/stale) and ask (non-derivable: no creds, Gitea
down, no linked issue, no ## Done-when, no verdict) are unchanged — the gate still fails closed, not
open. Two paths deliberately do not auto-grant and keep the bare exit 0 passthrough (normal
permissioning → one prompt): non-merge pull_request_write methods (auto-grant is scoped to
method=merge only), and the docs/process-only exemption. The exemption is a file-TYPE bypass, not
the a+b+c "provably reviewed & ready" proof, so it must not silently self-merge — critically, its set
includes .claude//.gitea//.husky/ (the gate, CI workflows, and git hooks themselves), so a PR
that weakens the gate still gets a human prompt (ersatztv#317 review nit). Verified by 8 pipe tests
(satisfied→allow, docs-only→passthrough, unticked→deny, stale→deny, red-CI→deny, no-verdict→ask,
no-creds→ask, non-merge→passthrough).
Process consequence: the state-derived gate is the consent on the satisfied path — do not also ask conversationally to merge a PR whose gate auto-grants. A separate human confirmation is still warranted only when the gate asks (state not derivable). This supersedes the "always confirm merge consent in-conversation per session" phrasing in the kickoff HARD CONSTRAINTS (updated in the same PR).
2026-07-12 — Release path rehearses migrations on a prod-DB copy before promoting (#315)
The CI migrations job proves a migration is well-formed against a fresh, empty DB (model-drift +
apply-to-fresh, per provider). That is necessary but not sufficient: it never exercises the migration —
or ErsatzTV's startup data steps (DatabaseMigratorService → DbInitializer + PopulatePathHashes
over the real MediaFile table) — against the accumulated prod SQLite, where row volume and
historical values differ. A migration green on a fresh DB can still fail or corrupt on prod, discovered
only mid-deploy after the container recreates.
Decision: before promoting a migration-bearing release, rehearse the new image's migrations against
a throwaway copy of the latest prod backup via scripts/migration-smoke.sh — boot the new image
against the copy, gate PASS on the Done applying database migrations log line (the migrator is a
BackgroundService running concurrently with Kestrel, so HTTP-readiness alone does not prove
migrations finished), FAIL on early container exit / a migration exception / timeout / not serving
afterwards. Always operates on a copy, never the live DB. Home: the script + docs are ours; wiring it
into the Komodo pre-deploy step (which already produces the backup) is a server-management concern.
Rationale: data-plane rigor — catch a bad migration on a disposable copy, not on live prod data.
See docs/ci-cd.md → Migration-on-prod-copy smoke. Cross-repo wiring tracked in server-management.
2026-07-13 — Release promotion: floating :prod, exact-image scan before manual deploy (#335)
Prod keeps the floating :prod image reference; PR #191's workflow-driven immutable pin bump is
closed as superseded. server-management#585 proved that automatic and manual promotions share
DeployStack and made a changed :prod digest trigger the fail-closed backup; #589 added the
prod-copy migration smoke before live recreation. Tagging and promotion remain separate: scan the
tag build's immutable :<version> image, then deploy manually. Daily auto-update is only a fallback,
so cut tags with enough runway before 03:00 to prevent an unscanned promotion. Refs #335 and
server-management#585/#589.