fix(743): make review-verdict/h10 unskippable — disable direct push to main + admin force-merge; fix(719) tag-only push (#749)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 35s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 43s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 49s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m20s

This commit was merged in pull request #749.
This commit is contained in:
2026-08-05 22:58:16 +00:00
10 changed files with 401 additions and 19 deletions
+32
View File
@@ -12,6 +12,38 @@ set -uo pipefail
[ "${ETV_SKIP_REBASE_CHECK:-}" = "1" ] && exit 0
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
# Tag-only push exemption (ersatztv#719): the release cut tags a commit on main while the local
# branch sits 1 commit behind origin/main, so H11 blocked EVERY release -- and its "rebase first"
# advice did not even apply, since no branch was being pushed. A tag push cannot revert anyone's
# merged work, which is the failure mode H11 exists to prevent, so skip the freshness check when
# EVERY ref being pushed is under refs/tags/. (See #719 for the observed flow.)
#
# Read pushed refs from stdin: git feeds pre-push hooks one line per ref, "<local ref> <local sha>
# <remote ref> <remote sha>" (.husky/pre-push forwards the lines it already captured). Ignore blank
# lines. VACUOUS-TRUTH GUARD: "all refs are tags" is trivially true when there are zero ref lines
# (hook run manually, stdin not forwarded, etc.) -- that would silently disable H11 for every push.
# Require at least one parsed ref line before granting the exemption; with zero lines, fall through
# to the existing branch-freshness check below (current behavior preserved).
#
# `[ -t 0 ] ||` so an interactive run does not hang waiting on a terminal: this script had no stdin
# reader before #719, and its own docs call "run by hand" a supported case. A TTY yields no ref
# lines, which is exactly the zero-line fall-through.
_h11_refs_seen=0
_h11_all_tags=1
[ -t 0 ] || while IFS=' ' read -r _h11_local_ref _h11_local_sha _h11_remote_ref _h11_remote_sha \
|| [ -n "${_h11_local_ref:-}" ]; do # `|| [ -n ... ]` also processes a final line with no trailing newline
[ -z "${_h11_local_ref:-}" ] && continue
_h11_refs_seen=1
case "${_h11_remote_ref:-}" in
refs/tags/*) ;;
*) _h11_all_tags=0 ;;
esac
_h11_local_ref=''
done
if [ "$_h11_refs_seen" = "1" ] && [ "$_h11_all_tags" = "1" ]; then
exit 0
fi
# Best-effort fetch of the latest main; offline / no network -> don't block.
git fetch origin main --quiet 2>/dev/null || exit 0
git rev-parse --verify --quiet origin/main >/dev/null 2>&1 || exit 0
+3 -2
View File
@@ -12,8 +12,9 @@ unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
# H11 (ersatztv#311): refuse to push a branch that is BEHIND origin/main — rebase, don't merge
# main in (a merge drags in files you never touched, e.g. legacy-BOM .cs, and trips the format
# hook on code that isn't yours). Fail-open; escape with ETV_SKIP_REBASE_CHECK=1.
./.claude/hooks/prepush-rebase-check.sh || exit 1
# hook on code that isn't yours). Fail-open; escape with ETV_SKIP_REBASE_CHECK=1. Exempts a
# tag-only push (ersatztv#719) — forward the ref lines captured above so it can tell.
printf '%s\n' "$_prepush_refs" | ./.claude/hooks/prepush-rebase-check.sh || exit 1
# H13 (ersatztv#416 session): refuse to push when a file in the pushed diff still has uncommitted
# working-tree/index changes — the pushed commit wouldn't match what you built/reviewed (the #416
+4 -2
View File
@@ -85,9 +85,11 @@ Every task that closes a Gitea issue MUST complete ALL of these before it is con
**Merge-consent is derived from state, not asserted (`## Done-when` convention — ersatztv#303 H6 + H10).** Any issue whose PR will merge to `main` should carry a `## Done-when` section in its **issue body** — a checklist of completion criteria (always include an "adversarial review passed" box; add per-issue criteria like tests-green, docs-updated, live-E2E). Two hooks derive merge-consent from it so a premature merge is blocked *by construction*, not by memory:
- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **auto-grants** a merge (emits `permissionDecision: allow`, so **no** redundant mechanical prompt fires) only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked **and** a `Review-verdict:` comment references the PR's *current head sha* (**H10**); **denies** on an unticked box, red CI, or a stale/negative review verdict; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no `Review-verdict:` comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state **is** the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate **asks** (ersatztv#314). **The H10 review-verdict convention**: after an adversarial/Codex review of a PR (or its latest fix commit), run **`scripts/post-review-verdict.sh <pr> <MERGEABLE|APPROVED|BLOCKED|NOT-MERGEABLE> [note]`** — it posts both the `Review-verdict: … @ <head-sha>` comment and the sha-bound `review-verdict/h10` commit status, proving the *latest* commit was reviewed rather than a stale earlier diff (ersatztv#242). Do not hand-write the comment: the **status** is the required check branch protection enforces, and a comment alone leaves it absent.
- **The gate is enforced server-side, per sha (ersatztv#622).** `review-verdict/h10` is a required status check on `main`. Because a commit status belongs to one sha, a commit pushed *after* an auto-merge is scheduled clears it and blocks the merge — closing the hole where `merge_when_checks_succeed` froze consent at scheduling time and Gitea later merged an unreviewed head. Renovate-authored and docs-only PRs are auto-passed by `.gitea/workflows/review-verdict.yml`, **except** when they touch `.claude/`, `.codex/`, `.gitea/`, `.husky/`, `scripts/` or `docker/ci/`. See `docs/ci-cd.md` → Review-verdict gate.
- `.husky/pre-push` → `prepush-donewhen.sh` — a fail-open backstop that blocks a direct `git push origin main` whose commits `fix #N` an issue with unticked boxes.
- `.husky/pre-push` → `prepush-donewhen.sh` — a fail-open backstop that blocks a direct `git push origin main` whose commits `fix #N` an issue with unticked boxes. **Since ersatztv#743 that push can no longer happen at all** (see below), so this hook is now belt-and-braces for a path the server refuses.
Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pass`** or `ETV_GITEA_TOKEN`; `ETV_GITEA_URL` overrides the base). Without them the merge hook asks and the push backstop is a no-op — the gate degrades to today's manual confirmation, never a silent pass. Docs-only PRs/pushes are exempt.
**`main` is PR-only — there is no direct-push path any more (ersatztv#743, `release.main-direct-push-disabled`).** Branch protection carries `enable_push: false` **and** `block_admin_merge_override: true`: a direct `git push origin HEAD:main` is refused server-side at pre-receive for every account including a site admin, the contents API is refused too, and an admin cannot `force_merge` past a missing or red required context. This is what makes `review-verdict/h10` load-bearing rather than conventional — Gitea only evaluates `status_check_contexts` on the PR merge path, so before this the whole gate was skippable with no forgery. Practically: **every** change to `main` goes through a PR, including a one-line docs fix. Tag pushes are unaffected (separate mechanism), so the release cut is unchanged.
Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pass`** or `ETV_GITEA_TOKEN`; `ETV_GITEA_URL` overrides the base). Without them the merge hook asks and the push backstop is a no-op — the gate degrades to today's manual confirmation, never a silent pass. Docs-only PRs are exempt from the *review-verdict* gate; the direct-push exemption is moot now that direct pushes are refused outright.
**The 7 mandatory completion steps and the `## Closing record` comment template** live in the
`closing-an-issue` skill (`.claude/skills/closing-an-issue/SKILL.md`) — invoke it (or `/done`)
+44 -3
View File
@@ -773,6 +773,40 @@ from `Build ErsatzTV Image / …` to `PR Gates / …`) does not affect merges. T
unreviewed commit from merging** (ersatztv#622). It is not produced by a job's success/failure; it
is a commit status that `scripts/post-review-verdict.sh` POSTs onto one specific sha.
**`main` is PR-only AND admin-override-proof, and it takes both to make the check load-bearing**
(ersatztv#743, `release.main-direct-push-disabled`). Gitea evaluates `status_check_contexts` when it
**merges a PR** — a direct `git push origin HEAD:main` never consults them. So until 2026-08-05 the
entire gate was skippable with no forgery at all, which was cheaper than every route enumerated in
#697. `main` now carries **two** fields, and citing either alone is a mistake:
- `enable_push: false` — a direct push is refused server-side at pre-receive (`Not allowed to push to
protected branch main`), for every account including a site admin. The contents API is refused too
— measured, HTTP 403 `user cannot commit to repo`. The web editor, upload, apply-patch, revert and
cherry-pick paths share that same `CanUserPush` predicate and are therefore expected to refuse as
well, but were not probed (source-attested only).
- `block_admin_merge_override: true` — without it (the default is `false`), a repo admin could
`POST /pulls/{n}/merge` with `force_merge: true` and merge straight past a missing or red
`review-verdict/h10`. Disabling push alone just moves the bypass from the push path to the merge
path, since `timothy` is admin and is the identity every session already uses. **Source-attested,
not probed** (Gitea 1.27 `CanBypassBranchProtection`): verifying it by experiment means merging an
unreviewed PR, so the field was set rather than measured. Setting it is safe under either
semantics; re-confirming the bypass itself rides with ersatztv#747.
**Operator recovery when a required context gets stuck.** `block_admin_merge_override: true` removes
the "Merge (admin)" / `force_merge: true` escape that used to unstick a PR whose required context was
absent or wrongly red — a recurring situation here (a killed run overwriting a newer green, an
advisory red counted into the combined status, a gate workflow that cannot post). That escape is gone
*by design*: it was also the bypass. The supported recovery is to fix the status
(re-run the job, or re-post the verdict with `scripts/post-review-verdict.sh`); the last resort is to
`PATCH .../branch_protections/main` setting `block_admin_merge_override: false`, merge, and set it
straight back. Do the last one deliberately and say so in the PR — it is the one action that
re-opens the hole this section exists to close.
Practical consequences: **every** change to `main` goes through a PR, including a one-line docs fix;
and the client-side Husky guards (H6/H11/H13) remain useful friction but were never the control —
they are fail-open and `--no-verify` bypasses them. Tag pushes are unaffected (separate mechanism;
`tag_protections` is empty), so the release cut in "Cutting a release" still works unchanged.
**The hole it closes.** `pretooluse-merge-consent.sh` proves its three consent conditions at the
moment the merge tool is called. Pass `merge_when_checks_succeed=true` and Gitea performs the merge
*later*, against whatever head is green then — while the Done-when and review-verdict checks were
@@ -927,9 +961,16 @@ as establishing that the gate cannot be forged (see the residual below, and ersa
workflow, though: Gitea injects a write-capable `GITEA_TOKEN` into every job and branch protection
binds the *context*, not its issuer. Gitea >=1.26 with the Actions default set to **Restricted**
(server-management#714) binds the injected token, but does not close the class either — not against
a personal token, and not against `RENOVATE_TOKEN` (ersatztv#742). **And none of it is necessary:
direct pushes to `main` are server-side permitted, so the gate can be skipped without any forgery
(ersatztv#743) — a push whitelist would close more of this class than the upgrade does.** The
a personal token, and not against `RENOVATE_TOKEN` (ersatztv#742). **And none of it was necessary:
direct pushes to `main` were server-side permitted, so the gate could be skipped without any forgery
(ersatztv#743). That is now CLOSED — `main` carries `enable_push: false` **and**
`block_admin_merge_override: true`, so it is reachable only through the PR merge path, the one path
on which Gitea evaluates `status_check_contexts`, and an admin cannot `force_merge` past them
(`release.main-direct-push-disabled` — neither field is citable alone).** Note the fix is *disabling* push, not whitelisting it: a
push whitelist naming `timothy` was measured to still admit the push, and `timothy` is the identity
every session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form would have
closed nothing. The block binds a site admin at pre-receive but not a credential that can first
PATCH branch protection off — an accepted residual, recorded in that decision. The
exemption path has separate defects of its own (ersatztv#698). One operational
consequence of the trigger change: a PR whose base is not `main` now gets **no**
`review-verdict/h10` at all. That is fail-closed. `edited` **is** now among the trigger's `types`
+3 -2
View File
@@ -36,7 +36,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `blazor.rollback-tag` | The commit immediately preceding the Blazor-removal merge is tagged `blazor-final` (not a `v*` tag, so it doesn't trigger a prod release build) as the documented rollback/restore path. | 2026-07-11 | [link](records/blazor/rollback-tag.md) |
| `blazor.ui-removed` | The legacy Blazor Server UI (`Pages/`, `Shared/`, `ViewModels/`, `Validators/`, MudBlazor + 8 other packages, Blazor Startup wiring) is fully deleted now that the SPA has parity; the legacy `MapWhen` branch is kept only for controllers/docs/OpenAPI/`LegacyUiRedirects`, and the catch-all fallback 302s any unmatched non-api/artwork/docs/openapi path to `/app`. | 2026-07-11 | [link](records/blazor/ui-removed.md) |
| `channel.origin-marker` | A new `Channel.Origin` (`ChannelOrigin` enum — `Unknown`/`UserCreated`/`AutoTuned`) records how a channel row was created and is stamped exactly once at insert (`AutoTuned` in `CreateChannelFromLineupHandler`, `UserCreated` in `CreateChannelHandler`), and is never mutated on a later edit. It is surfaced as a raw `origin` field on `ChannelResponseModel`; the SPA badges only `AutoTuned`. Rows predating the column read `Unknown` — provenance is **not** back-filled. | 2026-07-23 | [link](records/channel/origin-marker.md) |
| `ci.actions-credential-scoping` | Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. Do NOT add a `permissions:` key to constrain the injected `GITEA_TOKEN` while this instance is below Gitea 1.26.0 — it is silently a NO-OP there, which is worse than absent because it reads in review as a constraint. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter (`ci-image.yml`, tracked in #744 — deliberately NOT bundled here, because editing that file re-points `ci-image-pin` at the editing commit and reddens a blocking job). This record closes ONE route. It does not close the class, and four later sections say exactly what survives — read them before citing this record as a mitigation. | 2026-08-05 | [link](records/ci/actions-credential-scoping.md) |
| `ci.actions-credential-scoping` | Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. Do NOT add a `permissions:` key to constrain the injected `GITEA_TOKEN` on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP, which is worse than absent because it reads in review as a constraint. That version precondition NO LONGER HOLDS: this instance was upgraded 1.25.4 -> 1.27.1 on 2026-08-05. What has NOT changed is that the consequence is unverified — whether `permissions:` is honored here, and what this instance's default Actions token permission is, were both left UNPROBED (there is still no API surface: `/api/v1/settings/actions` 404s at 1.27.1). Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter (`ci-image.yml`, tracked in #744 — deliberately NOT bundled here, because editing that file re-points `ci-image-pin` at the editing commit and reddens a blocking job). This record closes ONE route. It does not close the class, and four later sections say exactly what survives — read them before citing this record as a mitigation. | 2026-08-05 | [link](records/ci/actions-credential-scoping.md) |
| `ci.batch-pushes-no-cancel-route` | Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes. | 2026-07-21 | [link](records/ci/batch-pushes-no-cancel-route.md) |
| `ci.build-once-rejected` | CI build-once (a shared compile artifact across jobs) was implemented, measured, and rejected for a 40-85% wall-clock regression; keep the #420 cross-run tree-identity skip instead. | 2026-07-18 | [link](records/ci/build-once-rejected.md) |
| `ci.cancelled-is-not-a-verdict` | Treat a `cancelled` conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor. | 2026-07-21 | [link](records/ci/cancelled-is-not-a-verdict.md) |
@@ -121,8 +121,9 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `process.subagent-drop-resume` | Treat a subagent connection drop as laptop sleep or transient network and re-resume via SendMessage — the work survives. | 2026-07-21 | [link](records/process/subagent-drop-resume.md) |
| `release.api-contract-ci-gate` | A PR touching `ErsatzTV/Controllers/Api/**` or `ErsatzTV.Core/Api/**` must ship regenerated OpenAPI artifacts (`v1.json`, `v1.d.ts`, `endpoint-index.md`) in the same diff, enforced by a blocking `api-docs` CI job that regenerates-and-diffs against a fresh build. | 2026-07-12 | [link](records/release/api-contract-ci-gate.md) |
| `release.done-when-merge-consent` | A PR may merge only when its linked issue's `## Done-when` checklist is fully ticked and the PR's CI is green, enforced by a PreToolUse hook on the Gitea merge tool (deny/allow/ask) plus a pre-push backstop for direct pushes to main. | 2026-07-12 | [link](records/release/done-when-merge-consent.md) |
| `release.format-as-you-touch-rebase` | A blocking `format` CI job runs `dotnet format --verify-no-changes` scoped only to the PR's changed `.cs` files (never the legacy BOM backlog), and a PR branch must be kept current by rebasing on `origin/main` (never merging main in), enforced by `.husky/pre-push` → `prepush-rebase-check.sh`. | 2026-07-12 | [link](records/release/format-as-you-touch-rebase.md) |
| `release.format-as-you-touch-rebase` | A blocking `format` CI job runs `dotnet format --verify-no-changes` scoped only to the PR's changed `.cs` files (never the legacy BOM backlog), and a PR branch must be kept current by rebasing on `origin/main` (never merging main in), enforced by `.husky/pre-push` → `prepush-rebase-check.sh`. H11 has ONE always-on carve-out, #719 — a push in which EVERY ref is under `refs/tags/` skips the freshness check, because a tag push cannot revert merged work, which is the failure mode H11 exists to prevent, and the release cut tags from a branch that is behind `origin/main` (observed on the v26.13.0 cut, #719). A push mixing branch and tag refs is still blocked, and so is a push with zero parsed ref lines (the exemption requires at least one, so empty stdin cannot vacuously disable H11). | 2026-07-12 | [link](records/release/format-as-you-touch-rebase.md) |
| `release.live-e2e-required` | A PR that changes an API write-path handler must include a live-E2E pass (driving the real endpoint/screen and confirming the round-trip through a subsequent read), not only unit/characterization tests, and must state whether live-E2E ran or wasn't required. | 2026-07-12 | [link](records/release/live-e2e-required.md) |
| `release.main-direct-push-disabled` | Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are unaffected (`tag_protections` governs those separately), so the release cut still works. | 2026-08-05 | [link](records/release/main-direct-push-disabled.md) |
| `release.merge-consent-autogrant` | When Done-when boxes are ticked, CI is green, and a fresh positive Review-verdict references head, the merge-consent hook emits `permissionDecision: allow` to actually suppress the redundant mechanical prompt — the derived state IS the consent, no separate conversational confirmation on that path. | 2026-07-12 | [link](records/release/merge-consent-autogrant.md) |
| `release.migration-rehearsal-prodcopy` | Before promoting a migration-bearing release, rehearse the new image's migrations against a throwaway copy of the latest prod backup (`scripts/migration-smoke.sh`), gating PASS on the migrator's completion log line rather than HTTP readiness alone. | 2026-07-12 | [link](records/release/migration-rehearsal-prodcopy.md) |
| `release.prepush-clean-worktree-guard` | A fail-open pre-push hook blocks a push when any file in the branch's diff vs `origin/main` also has uncommitted working-tree or index changes, since a stale-index commit (e.g. `git reset --soft` + `git add` over an edited-but-unstaged fix) can silently push, CI-test, and get reviewed a different tree than the one on disk. Scope is precise to pushed-diff files; escape hatch `ETV_ALLOW_DIRTY_PUSH=1`. | 2026-07-17 | [link](records/release/prepush-clean-worktree-guard.md) |
@@ -5,7 +5,7 @@ status: active
since: '2026-08-05'
supersedes: none
superseded-by: none
rule: 'Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. Do NOT add a `permissions:` key to constrain the injected `GITEA_TOKEN` while this instance is below Gitea 1.26.0 — it is silently a NO-OP there, which is worse than absent because it reads in review as a constraint. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter (`ci-image.yml`, tracked in #744 — deliberately NOT bundled here, because editing that file re-points `ci-image-pin` at the editing commit and reddens a blocking job). This record closes ONE route. It does not close the class, and four later sections say exactly what survives — read them before citing this record as a mitigation.'
rule: 'Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. Do NOT add a `permissions:` key to constrain the injected `GITEA_TOKEN` on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP, which is worse than absent because it reads in review as a constraint. That version precondition NO LONGER HOLDS: this instance was upgraded 1.25.4 -> 1.27.1 on 2026-08-05. What has NOT changed is that the consequence is unverified — whether `permissions:` is honored here, and what this instance''s default Actions token permission is, were both left UNPROBED (there is still no API surface: `/api/v1/settings/actions` 404s at 1.27.1). Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter (`ci-image.yml`, tracked in #744 — deliberately NOT bundled here, because editing that file re-points `ci-image-pin` at the editing commit and reddens a blocking job). This record closes ONE route. It does not close the class, and four later sections say exactly what survives — read them before citing this record as a mitigation.'
signals: 'admin password in CI secrets, registry credential scope, ETV_STATUS_AUTH can write statuses, forge review-verdict/h10, head-resolved workflow holds credentials, Gitea token scopes, no status scope, write:package vs write:repository, permissions key no-op, GITEA_TOKEN default read/write, Restricted default token permissions, orphan secret, deploy key in secret store, toolchain image overwrite, prod floating tag write · paths: `.gitea/workflows/docker-build.yml`, `.gitea/workflows/ci-image.yml`, `.gitea/workflows/renovate.yml`, `scripts/ci-detect-already-validated.sh` · issues: #697, #672, #698, #742, #743, #420, server-management#714'
mechanics: 'PAT `ci-registry-scoped-697`, scopes `write:package,read:repository`, stored as repo Actions secret `REGISTRY_PASSWORD`; `REGISTRY_USER` remains `timothy`. Verified 2026-08-05 on Gitea 1.25.4: registry push of a probe tag SUCCEEDED; `GET /commits/{sha}/status` 200; `POST /statuses/{sha}` REFUSED HTTP 403 `token does not have at least one of required scope(s), required=[write:repository], token scope=write:package,read:repository`. Probe artifacts deleted, confirmed 404. NOT measured with this token: the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea''s scope model (write implies read per category, read at tag `v1.25.4`) — INFERRED. Note WHICH run proves which: only the `container:` pull is exercised by a PR. `cache-to`/`cache-from` and the base-image pull are confined to the `build` job, which carries `if: github.event_name != ''pull_request''`, so they are first exercised on the post-merge push to `main` — AFTER the merge gate has passed. A wrong inference there reddens main, not the PR.'
---
@@ -59,15 +59,30 @@ and jobs reach the runner's docker daemon.
docs, NOT verified (2026-08-05): `permissions:` landed in 1.26.0 (Gitea PR #36173); no `app.ini` lever
at any version; Gitea rejects GitHub's `statuses`/`checks` scopes.
**Version caveat — this record's measurements are pinned to 1.25.4, the instance is now 1.27.1.**
The instance was upgraded mid-session on 2026-08-05 (#743). Everything above measured on 1.25.4 is
therefore a *dated* claim, not a current one: the scope enum, the `reqRepoWriter` gate and the 403
probe were all taken pre-upgrade and have NOT been re-run. They are recorded honestly as of their
date and are the best evidence available, but do not cite them as current behaviour without
re-probing. Re-verification of the 1.25.4-pinned claims across the CI docs is tracked separately.
**Surviving routes — this record is not a mitigation for any of them.** `RENOVATE_TOKEN` is a
`write:repository` bot PAT in the same store, posting with a real creator, and cannot be scoped down
because Renovate needs repo write (#742). The injected `GITEA_TOKEN` is write-capable in every job;
only Gitea >=1.26 with the Actions default set to **Restricted** binds it (server-management#714). A
only Gitea >=1.26 with the Actions default set to **Restricted** binds it (server-management#714) —
the version half of that condition is now satisfied (1.27.1) but the *default* half is unverified, so
treat this route as still open until probed. A
collaborator's own token always can. `docker-build.yml` publishes `:prod` from a `v*` tag push and a tag
may point at ANY commit — a prod image with no PR, review or status (tag protections are empty).
**And none of it is necessary: direct pushes to `main` are server-side permitted, so the gate is
bypassable with no forgery at all (#743)** — a push whitelist closes more of this class than the 1.26
upgrade. Treat this list as "at least these", never exhaustive. `SERVERMGMT_DEPLOY_KEY` remains in the
**And none of it was necessary: direct pushes to `main` were server-side permitted, so the gate was
bypassable with no forgery at all (#743).** That route is now closed — `main` carries
`enable_push: false` (`release.main-direct-push-disabled`), which removes `main` as a destination for
every write-only credential in this list, including the injected `GITEA_TOKEN` and `RENOVATE_TOKEN`.
It does not remove them as *forgery* routes on the PR path, and it does not bind an admin credential,
which can PATCH the protection off first. Correction to this record's earlier wording: a push
*whitelist* would NOT have closed more of the class than the upgrade — measured 2026-08-05, a
whitelist naming `timothy` still admitted the push, and every credential here acts as `timothy`.
Treat this list as "at least these", never exhaustive. `SERVERMGMT_DEPLOY_KEY` remains in the
store though its `bump-prod-compose` job went in `1b5efd7b9`, and its key on `timothy/server-management`
is `read_only: false` — write access to the repo holding prod's GitOps stack definitions. Left in place
by explicit decision 2026-08-05; recorded so it is accepted, not forgotten. Severity throughout: push
@@ -5,8 +5,8 @@ status: active
since: '2026-07-12'
supersedes: none
superseded-by: none
rule: A blocking `format` CI job runs `dotnet format --verify-no-changes` scoped only to the PR's changed `.cs` files (never the legacy BOM backlog), and a PR branch must be kept current by rebasing on `origin/main` (never merging main in), enforced by `.husky/pre-push` → `prepush-rebase-check.sh`.
signals: 'format-as-you-touch, rebase not merge, BOM backlog · paths: `.husky/pre-push`, `.claude/hooks/prepush-rebase-check.sh` · issues: #311 (H11), #309, #310, #269, #312'
rule: 'A blocking `format` CI job runs `dotnet format --verify-no-changes` scoped only to the PR''s changed `.cs` files (never the legacy BOM backlog), and a PR branch must be kept current by rebasing on `origin/main` (never merging main in), enforced by `.husky/pre-push` → `prepush-rebase-check.sh`. H11 has ONE always-on carve-out, #719 — a push in which EVERY ref is under `refs/tags/` skips the freshness check, because a tag push cannot revert merged work, which is the failure mode H11 exists to prevent, and the release cut tags from a branch that is behind `origin/main` (observed on the v26.13.0 cut, #719). A push mixing branch and tag refs is still blocked, and so is a push with zero parsed ref lines (the exemption requires at least one, so empty stdin cannot vacuously disable H11).'
signals: 'format-as-you-touch, rebase not merge, BOM backlog, tag-only push exemption, H11 blocks release cut, refs/tags pre-push, vacuous-truth guard · paths: `.husky/pre-push`, `.claude/hooks/prepush-rebase-check.sh`, `scripts/tests/test_prepush_rebase_check_tag_exemption.py` · issues: #311 (H11), #719, #309, #310, #269, #312'
mechanics: '`docs/contributing.md` §7; `.claude/hooks/prepush-rebase-check.sh`; `npm run check:api`'
---
@@ -36,5 +36,22 @@ git hook has no "ask"); deliberate escape `ETV_SKIP_REBASE_CHECK=1`. This supers
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.)
**2a. The tag-only carve-out (#719).** H11 fired on the release cut: tagging a commit on `main` from
a branch that is behind `origin/main` tripped the freshness check, and the rebase advice it printed
did not even apply — no branch was being pushed. Observed while cutting `v26.13.0` (#719); note
`docs/ci-cd.md` → "Cutting a release" documents the tag step itself, not the release-notes-PR flow
that leaves the branch behind, so the frequency is attested by #719 rather than by that doc. The hook now reads git's pre-push ref lines (`<local ref> <local sha>
<remote ref> <remote sha>`) and exits 0 when every parsed line's *remote* ref is under `refs/tags/`.
Two details are load-bearing and easy to regress:
- `.husky/pre-push` consumes stdin into `$_prepush_refs` before any guard runs, so it must **forward**
those lines (`printf '%s\n' "$_prepush_refs" | …`). Without that the check receives EOF and the
exemption is dead code that silently never fires. The unit tests drive the hook directly and would
still pass, so this wiring is not covered by them.
- The exemption requires **at least one** parsed ref line. "All refs are tags" is vacuously true for
zero lines, which would disable H11 for every push; with no lines the hook falls through to the
normal freshness check. `scripts/tests/test_prepush_rebase_check_tag_exemption.py` pins both the
negative control (branch push from a behind branch still blocked), the mixed branch+tag case, and
the two zero-line cases.
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).
@@ -0,0 +1,79 @@
---
key: release.main-direct-push-disabled
title: '2026-08-05 — `main` refuses direct pushes (`enable_push: false`), because a push whitelist would have been a no-op here (#743)'
status: active
since: '2026-08-05'
supersedes: none
superseded-by: none
rule: 'Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are unaffected (`tag_protections` governs those separately), so the release cut still works.'
signals: 'direct push to main, push whitelist, enable_push false, branch protection bypass, review-verdict/h10 bypassable without forging, merge consent derived not asserted, pre-receive hook declined, Not allowed to push to protected branch, protected branch, tag_protections, release tag push, GITEA_TOKEN repo write, RENOVATE_TOKEN, site admin bypass, PR-only flow · paths: `docs/ci-cd.md` · issues: #743, #697, #698, #622, #672, #706, #742, server-management#714'
mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returns `[]`; repo is `fork: false`, `mirror: false`. NOT measured, source-attested only (Gitea 1.27 `CanBypassBranchProtection`, `services/pull/check.go`, `routers/private/hook_pre_receive.go`): that `block_admin_merge_override: false` would have let an admin `force_merge` past the required contexts — the field was set to true rather than probed, since probing it means merging an unreviewed PR. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout.'
---
**Why a whitelist was the wrong shape.** #743 proposed "a push whitelist on `main` (or disable direct
push entirely)" as if the two were interchangeable. They are not, and which one is right depends on a
fact about *this* instance: the only accounts with repository write are `timothy` (a site admin) and
`renovate`. Every credential in the threat model — an agent session, a collaborator PAT, the
`GITEA_TOKEN` Gitea injects into every Actions job — authenticates as one of those two, and
overwhelmingly as `timothy`. A whitelist admitting `timothy` therefore admits precisely the identity
the control is supposed to constrain. It would have ticked the issue's box while changing nothing.
This was measured, not reasoned: the same push was refused under `enable_push: false` and accepted
under a whitelist naming `timothy`.
**Disabling push alone was NOT enough, and the reason is the same argument twice.** The first draft of
this record disabled direct push and concluded that `review-verdict/h10` was therefore load-bearing.
An independent review caught that this repeated on the merge path exactly the mistake it had just
diagnosed on the push path. The push argument was: a whitelist naming `timothy` fails because
`timothy` is the identity every credential already holds. The merge path had the identical shape —
`block_admin_merge_override` defaulted to `false`, so `CanBypassBranchProtection` returned true for a
repo admin and `POST /pulls/{n}/merge` with `force_merge: true` merged straight past a missing or red
`h10`. One API call, cheaper than the push route it replaced. **An admin-shaped control that exempts
the only admin exempts everybody.** Both fields are now set; treat them as one control, and never
cite `enable_push: false` alone as the reason the gate holds.
**What this actually closes, and what it does not.** It closes the *write-only* credential routes,
which is most of #743's own "who can do it" list: the injected `GITEA_TOKEN` (repo write, not admin),
`RENOVATE_TOKEN`, and any non-admin collaborator PAT. Those can no longer reach `main` at all, by any
path that skips the gate.
It does **not** close the admin route. `timothy` is a site admin, so a credential holding that
identity can `PATCH` either field off, act, and restore it — the exact sequence used to *prove* the
push semantics above. Closing that requires agent sessions to run as a scoped non-admin credential,
which is a different change with its own cost (packages live in a user namespace; see the "Admin
ownership is a real residual" section of `ci.actions-credential-scoping`). Recorded as an accepted
residual rather than fixed here, so it is not mistaken for covered. The severity bound from #697 and
#743 is unchanged throughout: push access is required, so this is a compromised contributor or a
subverted automated session, never an anonymous attacker.
**Which write surfaces were enumerated.** `git push` (measured, refused), the contents API and by
extension the web editor / upload path (measured on a probe branch, refused HTTP 403 — they share the
`CanUserPush` predicate, which has no admin special-case and no `unprotected_file_patterns` carve-out
since that field is empty), apply-patch / revert / cherry-pick (source-attested, same predicate),
force push (`enable_force_push: false`), default-branch deletion (separately refused), and fork-sync /
mirror (not applicable: `fork: false`, `mirror: false`). Merge remains the one intended path.
**Why the release cut does not deadlock.** #743 flagged that the tag path had to keep working, and
#719 documents H11 blocking a tag-only push on every release cut. Branch protection is scoped to
`refs/heads/main`; tags are governed by an entirely separate mechanism, and `tag_protections` on this
repo is empty, so tag pushes are unrestricted by anything except ordinary write permission. Demonstrated
rather than assumed: from one worktree, the branch push to `main` was refused and a tag push succeeded.
Do not conflate the two mechanisms — disabling branch push says nothing about tags, and a future
tag-protection rule would not inherit from this one.
**The `docker-build.yml` `persist-credentials` question (#743's fourth box), decided and deferred.**
Its six `actions/checkout` steps omit `persist-credentials: false`, so a head-resolved job keeps a
write-capable credential in `.git/config`. It *should* be set — but not blind, and not in this PR,
because two steps run `git fetch --no-tags --depth=100 origin "$base_ref" || true` and feed the result
into the changed-file skip logic. That `|| true` means a credential regression does not fail the job;
it silently yields an empty changed-file set, and the skip logic then reads "nothing changed". The repo
is public, so anonymous fetch is *expected* to cover it — expected is not measured, and the failure
mode is silent, which is the shape that has burned this repo before. The correct order is: drop the
`|| true` masking so a fetch failure is loud, then set `persist-credentials: false` and confirm both
jobs still compute a non-empty changed set on a PR that genuinely changes files.
**Why this is not redundant with the Husky pre-push hooks.** `.husky/pre-push` guards (H6 done-when,
H11 rebase, H13 clean worktree) are client-side and deliberately fail-open — a git hook cannot prompt.
They are not installed in CI, not present in a fresh clone until `husky` runs, and `--no-verify`
bypasses them, which the worktree workflow uses routinely. They are good friction against mistakes and
were never a control against a credential. This record is the server-side half; the hooks remain useful
and unchanged.
@@ -189,9 +189,17 @@ described as one:
inherited, #742), a collaborator's own token, and the `v*` tag push — which matters less for
forging this status than for what else it does: `docker-build.yml` publishes `:prod` from a tagged
ref, and a tag may point at any commit, so it ships a prod image with no PR, review or status.
None of which is even required — direct pushes to `main` are server-side permitted, so the gate
can be skipped without forging anything (#743). So the "careless
change rather than a hostile one" posture below still
None of which used to be even required — direct pushes to `main` were server-side permitted, so
the gate could be skipped without forging anything (#743). **That route is now closed**
(`release.main-direct-push-disabled`): `main` carries `enable_push: false` *and*
`block_admin_merge_override: true`, so every change reaches `main` through the PR merge path,
which is the only path on which these required contexts are evaluated. What survives is the
forgery list above — those routes post a status rather than skip it, so they are still real —
**plus one skip route that is not forgery at all**: a credential that can `PATCH` branch
protection can turn either field off, act, and restore it. `timothy` is a site admin, so every
session holds that capability; it is an accepted residual, recorded in
`release.main-direct-push-disabled` and `ci.actions-credential-scoping`, not a closed route. So
the "careless change rather than a hostile one" posture below still
describes the repo accurately — it is simply no longer *this* workflow that is the weakest link.
An untrusted-contributor repo would still need the classification moved somewhere no PR can
reach (server-side policy), not merely a base-pinned definition.
@@ -0,0 +1,186 @@
"""Tests for the tag-only-push exemption in `.claude/hooks/prepush-rebase-check.sh` (ersatztv#719).
H11 refuses to push a branch that is behind `origin/main`, to force a rebase instead of a merge.
But the release cut tags a commit on `main` from a branch that is behind `origin/main`, so H11
blocked every release -- and its "rebase first" advice did not even apply, because no branch was
being pushed. (Observed while cutting v26.13.0; see #719. `docs/ci-cd.md` -> "Cutting a release"
documents the tag step itself, not the release-notes-PR flow that puts the branch behind.) A tag
push cannot revert anyone's merged work (the failure mode H11 exists to prevent), so the fix skips
the freshness check when EVERY ref being pushed is under `refs/tags/`.
These tests use real local git repositories (a bare "origin" plus a work tree pushed one commit
behind it) rather than stubbing `git`, because the hook's decision hinges on genuine
`git fetch` / `merge-base` / `rev-list` behavior against an origin that has moved.
`test_zero_ref_lines_does_not_exempt` is the load-bearing negative case from the issue: "all pushed
refs are tags" is vacuously true over zero ref lines, so a naive implementation would disable H11
entirely whenever stdin is empty (hook run manually, or a caller that forgot to forward it). The fix
must require at least one parsed ref line before granting the exemption.
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
HOOK = REPO_ROOT / ".claude" / "hooks" / "prepush-rebase-check.sh"
DUMMY_SHA_A = "a" * 40
DUMMY_SHA_B = "b" * 40
def _git(args, cwd):
r = subprocess.run(["git", *args], cwd=str(cwd), capture_output=True, text=True)
assert r.returncode == 0, f"git {' '.join(args)} failed: {r.stderr}"
return r.stdout
@pytest.fixture
def behind_repo(tmp_path):
"""A work tree whose local `main` is exactly one commit behind `origin/main`."""
origin = tmp_path / "origin.git"
_git(["init", "--bare", "-q", str(origin)], cwd=tmp_path)
work = tmp_path / "work"
_git(["init", "-q", "-b", "main", str(work)], cwd=tmp_path)
_git(["config", "user.email", "test@example.com"], cwd=work)
_git(["config", "user.name", "Test"], cwd=work)
(work / "f.txt").write_text("one\n")
_git(["add", "f.txt"], cwd=work)
_git(["commit", "-q", "-m", "initial"], cwd=work)
_git(["remote", "add", "origin", str(origin)], cwd=work)
_git(["push", "-q", "-u", "origin", "main"], cwd=work)
# The bare repo's HEAD symref still points at the (nonexistent) default branch until something
# sets it explicitly; without this, `git clone` below checks out an unborn HEAD and "main" never
# exists as a local branch in `advancer`.
_git(["symbolic-ref", "HEAD", "refs/heads/main"], cwd=origin)
# Advance origin/main independently, via a second clone, so `work`'s local `main` falls behind.
advancer = tmp_path / "advancer"
_git(["clone", "-q", str(origin), str(advancer)], cwd=tmp_path)
_git(["config", "user.email", "test@example.com"], cwd=advancer)
_git(["config", "user.name", "Test"], cwd=advancer)
(advancer / "f.txt").write_text("two\n")
_git(["add", "f.txt"], cwd=advancer)
_git(["commit", "-q", "-m", "advance"], cwd=advancer)
_git(["push", "-q", "origin", "main"], cwd=advancer)
return work
def _run_hook(cwd, stdin_text):
env = dict(os.environ)
for k in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"):
env.pop(k, None)
return subprocess.run(
["bash", str(HOOK)],
cwd=str(cwd),
input=stdin_text,
env=env,
capture_output=True,
text=True,
)
def test_tag_only_push_from_a_behind_branch_is_allowed(behind_repo):
"""The fix: a tag-only push must not be blocked by H11 even though the branch is behind."""
stdin = f"refs/tags/v1.0.0 {DUMMY_SHA_A} refs/tags/v1.0.0 {DUMMY_SHA_B}\n"
r = _run_hook(behind_repo, stdin)
assert r.returncode == 0, f"tag-only push was blocked: {r.stdout}{r.stderr}"
def test_tag_only_push_ignores_blank_lines(behind_repo):
stdin = f"\nrefs/tags/v1.0.0 {DUMMY_SHA_A} refs/tags/v1.0.0 {DUMMY_SHA_B}\n\n"
r = _run_hook(behind_repo, stdin)
assert r.returncode == 0, f"tag-only push (with blank lines) was blocked: {r.stdout}{r.stderr}"
def test_negative_control_branch_push_from_behind_is_still_blocked(behind_repo):
"""Required by #719: the fix must not weaken H11 for ordinary branch pushes."""
stdin = f"refs/heads/feature {DUMMY_SHA_A} refs/heads/feature {DUMMY_SHA_B}\n"
r = _run_hook(behind_repo, stdin)
assert r.returncode == 1, "a branch push from a behind branch was allowed"
assert "H11" in r.stdout
def test_mixed_branch_and_tag_push_is_still_blocked(behind_repo):
stdin = (
f"refs/heads/feature {DUMMY_SHA_A} refs/heads/feature {DUMMY_SHA_B}\n"
f"refs/tags/v1.0.0 {DUMMY_SHA_A} refs/tags/v1.0.0 {DUMMY_SHA_B}\n"
)
r = _run_hook(behind_repo, stdin)
assert r.returncode == 1, "a mixed branch+tag push was allowed through the tag exemption"
assert "H11" in r.stdout
def test_zero_ref_lines_does_not_exempt(behind_repo):
"""Vacuous-truth guard: 'all refs are tags' is trivially true over zero lines. Empty stdin
(hook run manually, or a caller that forgot to forward the ref lines) must fall through to the
existing behind-origin/main check, not silently disable H11."""
r = _run_hook(behind_repo, "")
assert r.returncode == 1, "empty stdin vacuously granted the tag exemption"
assert "H11" in r.stdout
def test_zero_ref_lines_of_only_blank_lines_does_not_exempt(behind_repo):
r = _run_hook(behind_repo, "\n\n\n")
assert r.returncode == 1, "stdin of only blank lines vacuously granted the tag exemption"
assert "H11" in r.stdout
# --- final line with NO trailing newline -------------------------------------------------------
# `read` returns non-zero on an unterminated final line, so a bare `while read` silently DROPS it.
# Both directions matter and they fail differently, which is why each is pinned:
# - tag-only, unterminated -> the line is dropped, no refs are seen, and H11 blocks the release
# tag push again, i.e. #719 quietly returns.
# - mixed, unterminated -> the BRANCH line is dropped, leaving only tag refs, and the
# exemption is granted for a push that includes a branch. That is the dangerous direction.
# Git always newline-terminates its ref lines and `.husky/pre-push` re-adds one via `printf '%s\n'`,
# so this is reachable only on a hand-piped run — but the guard is cheap and the failure is silent.
def test_unterminated_final_line_tag_only_is_still_exempt(behind_repo):
stdin = f"refs/tags/v1.0.0 {DUMMY_SHA_A} refs/tags/v1.0.0 {DUMMY_SHA_B}" # no trailing \n
r = _run_hook(behind_repo, stdin)
assert r.returncode == 0, f"unterminated tag-only line was dropped, reinstating #719: {r.stdout}"
def test_tty_stdin_does_not_hang_and_does_not_exempt(behind_repo):
"""The hook gained a stdin reader in #719; before that it read nothing, and its own docs call
'run by hand' a supported case. Without the `[ -t 0 ] ||` guard an interactive run blocks
forever waiting on the terminal. A pty gives it a real TTY on fd 0; the `timeout` turns a
regression into a clean failure instead of a hung CI job."""
primary, secondary = os.openpty()
try:
r = subprocess.run(
["bash", str(HOOK)],
cwd=str(behind_repo),
stdin=secondary,
capture_output=True,
text=True,
timeout=30,
)
except subprocess.TimeoutExpired:
pytest.fail("hook hung on TTY stdin — the `[ -t 0 ] ||` guard is missing or ineffective")
finally:
os.close(primary)
os.close(secondary)
# A TTY yields no ref lines, so this is the zero-line fall-through: H11 still applies.
assert r.returncode == 1, "TTY stdin vacuously granted the tag exemption"
assert "H11" in r.stdout
def test_unterminated_final_branch_line_is_not_swallowed_into_the_exemption(behind_repo):
"""The dangerous direction: if the unterminated BRANCH line is dropped, only tag refs remain
and a branch push wins the tag exemption."""
stdin = (
f"refs/tags/v1.0.0 {DUMMY_SHA_A} refs/tags/v1.0.0 {DUMMY_SHA_B}\n"
f"refs/heads/feature {DUMMY_SHA_A} refs/heads/feature {DUMMY_SHA_B}" # no trailing \n
)
r = _run_hook(behind_repo, stdin)
assert r.returncode == 1, "an unterminated branch ref was swallowed into the tag exemption"
assert "H11" in r.stdout