Compare commits
153
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8ea62bfa0 | ||
|
|
fcdc381246 | ||
|
|
9fc54fed87 | ||
|
|
3541683c99 | ||
|
|
e7f794057d | ||
|
|
722f0057e8 | ||
|
|
a8240aba72 | ||
|
|
7fa223a567 | ||
|
|
9f0f21ec52 | ||
|
|
c249ca4110 | ||
|
|
41fd1f64de | ||
|
|
74a6e01712 | ||
|
|
1149d96eff | ||
|
|
cb11116d4f | ||
|
|
0e92c147a4 | ||
|
|
ae314aa5ea | ||
|
|
131c63f7f4 | ||
|
|
352305ade8 | ||
|
|
c84bdd2427 | ||
|
|
900dac9832 | ||
|
|
8cda92748e | ||
|
|
022a05acb2 | ||
|
|
81358dec5a | ||
|
|
20875b0ca7 | ||
|
|
dc3f158bcb | ||
|
|
868ab657d8 | ||
|
|
11f92761af | ||
|
|
761a71278d | ||
|
|
0977f04174 | ||
|
|
c67b33cdcd | ||
|
|
17c65005f5 | ||
|
|
32e8578438 | ||
|
|
78593ace9f | ||
|
|
4c2ceccaf7 | ||
|
|
87d90f330c | ||
|
|
0f4552d093 | ||
|
|
c30847204c | ||
|
|
61ed6a7955 | ||
|
|
45cada07e4 | ||
|
|
d793342f50 | ||
|
|
786d1c3c4b | ||
|
|
47a5582443 | ||
|
|
f075d1417c | ||
|
|
d4c00f7567 | ||
|
|
c325cd6ee4 | ||
|
|
fe05526ef4 | ||
|
|
fa06da21f3 | ||
|
|
e656583247 | ||
|
|
6713b82042 | ||
|
|
d137d70bf2 | ||
|
|
a84fa7665b | ||
|
|
af9c2349a7 | ||
|
|
a4eea6460b | ||
|
|
d62335bc8d | ||
|
|
dd836c1f46 | ||
|
|
ae5a32290f | ||
|
|
23cef5c9d1 | ||
|
|
8885fdd9d2 | ||
|
|
383171dfa4 | ||
|
|
433e8f81c8 | ||
|
|
25ec811c73 | ||
|
|
ccc604aef7 | ||
|
|
985a6e1505 | ||
|
|
fba895852c | ||
|
|
2f661be684 | ||
|
|
bbd919bfe3 | ||
|
|
b71bf4d7b2 | ||
|
|
7355873c39 | ||
|
|
366a0f9040 | ||
|
|
a083c851b3 | ||
|
|
b1d5fbefcb | ||
|
|
595c819de1 | ||
|
|
929dff835a | ||
|
|
f7a0da4051 | ||
|
|
ea1519871d | ||
|
|
4454f1adfc | ||
|
|
3df98d247f | ||
|
|
d6e4426aa9 | ||
|
|
053982180d | ||
|
|
c0a70d724a | ||
|
|
b6ac7febbe | ||
|
|
4bd5cf9e91 | ||
|
|
16a6e2790f | ||
|
|
0d2cd89782 | ||
|
|
b3bc3ed115 | ||
|
|
02ecd484ed | ||
|
|
8575c02ad2 | ||
|
|
0f8e73ab05 | ||
|
|
f34828806d | ||
|
|
ff65e5e7c9 | ||
|
|
b1c4cdb6d7 | ||
|
|
91a9fbf81a | ||
|
|
a956cc6b8d | ||
|
|
a9341d8415 | ||
|
|
7dba36cd58 | ||
|
|
99966f8bd7 | ||
|
|
0c970fc7c1 | ||
|
|
9e0e11030b | ||
|
|
80cd0a9ee9 | ||
|
|
443df1391b | ||
|
|
1b5dd80681 | ||
|
|
11e4d74256 | ||
|
|
3489254c8b | ||
|
|
4008f4b574 | ||
|
|
1fc24e8cf6 | ||
|
|
4a56d54283 | ||
|
|
fd58b1df33 | ||
|
|
14e075174b | ||
|
|
692affca97 | ||
|
|
350509f8bb | ||
|
|
f119bfcb29 | ||
|
|
b6b4fb6619 | ||
|
|
4e042fb7de | ||
|
|
ade14a7521 | ||
|
|
02e1c583e9 | ||
|
|
e9de5dcb1b | ||
|
|
e1d211cd1b | ||
|
|
e4c346e649 | ||
|
|
86e9ad41a6 | ||
|
|
4b12125f4d | ||
|
|
626cbfbf9d | ||
|
|
de9432ff49 | ||
|
|
f61764d051 | ||
|
|
9ba397e1dd | ||
|
|
92db6dc81e | ||
|
|
208ae00953 | ||
|
|
181847f70a | ||
|
|
a0203f2863 | ||
|
|
63256afd96 | ||
|
|
a380129700 | ||
|
|
efadbec297 | ||
|
|
a7d91bf15a | ||
|
|
fb55929711 | ||
|
|
8fd9eae0bf | ||
|
|
dd0f75f1b6 | ||
|
|
4cd6929735 | ||
|
|
4b7ede80b8 | ||
|
|
9da0020462 | ||
|
|
1506e5a545 | ||
|
|
58def5faa1 | ||
|
|
69b30d15ec | ||
|
|
8141ac7807 | ||
|
|
06b8081664 | ||
|
|
3ec73f3769 | ||
|
|
848216e277 | ||
|
|
9b99a23835 | ||
|
|
3a88f5dc3d | ||
|
|
566cabea6d | ||
|
|
b53c3bab47 | ||
|
|
376aad6774 | ||
|
|
090a29db3d | ||
|
|
a4df8f7958 | ||
|
|
febaad77d7 |
@@ -7,7 +7,7 @@ set -uo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# git hook: decides by exit code, and its stdout is live progress text.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin decisions-guard "" stream || true
|
||||
|
||||
@@ -19,7 +19,7 @@ set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin design-sync-reminder "${1:-}" capture || true
|
||||
|
||||
@@ -7,7 +7,7 @@ set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin posttooluse-worktree-marker "" capture || true
|
||||
|
||||
@@ -17,7 +17,7 @@ set -uo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# git hook: decides by exit code, and its stdout is live progress text.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin prepush-clean-worktree-check "" stream || true
|
||||
|
||||
@@ -14,7 +14,7 @@ set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# git hook: decides by exit code, and its stdout is live progress text.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin prepush-donewhen "" stream || true
|
||||
|
||||
@@ -11,7 +11,7 @@ set -uo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# git hook: decides by exit code, and its stdout is live progress text.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin prepush-rebase-check "" stream || true
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
# no forcing function was the one that got defaulted. A check that runs beats a rule you must remember
|
||||
# (the same reasoning as pretooluse-bom-guard.sh).
|
||||
#
|
||||
# SCOPE — gate EVERY dispatch that names no model, not just implementer-looking ones. The first cut
|
||||
# tried to be clever: it fired only when the prompt text matched implementer signals (`git commit`,
|
||||
# `worktree`, `fixes #`…). Review of that version (#583) confirmed the heuristic both over- and
|
||||
# SCOPE — gate EVERY dispatch that names no model, not just implementer-looking ones. A NARROWER
|
||||
# cut was TRIED AND REJECTED: it fired only when the prompt text matched implementer signals (`git
|
||||
# commit`, `worktree`, `fixes #`…). Measured (#583), the heuristic both over- and
|
||||
# under-fired — a read-only recon brief mentioning "worktree" nagged, while "author the change and
|
||||
# open a PR", "land this on the branch" and "make the changes and commit them" all sailed through
|
||||
# silently, i.e. it missed the exact case it existed to catch. Prompt prose is not a reliable signal
|
||||
@@ -42,7 +42,7 @@ set -uo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin pretooluse-agent-model "" capture || true
|
||||
|
||||
@@ -6,7 +6,7 @@ set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin pretooluse-agent-ram "" capture || true
|
||||
|
||||
@@ -5,7 +5,7 @@ set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin pretooluse-bash-guard "" capture || true
|
||||
|
||||
@@ -20,7 +20,7 @@ set -uo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin pretooluse-bom-guard "" capture || true
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
# "re-review the fix commit, not just the initial PR diff").
|
||||
#
|
||||
# EVERY ONE OF THOSE IS A SNAPSHOT, taken when the merge tool is called. The window is SMALL for an
|
||||
# immediate merge and UNBOUNDED for a scheduled one. Small is not zero, and this comment used to say
|
||||
# "sound", which is the overclaim ersatztv#778 removed: this hook returns `allow` and a SEPARATE call
|
||||
# immediate merge and UNBOUNDED for a scheduled one. Small is not zero, and calling this gate
|
||||
# "sound" is the overclaim ersatztv#778 removed: this hook returns `allow` and a SEPARATE call
|
||||
# performs the merge, so a push can still land in between. The merge API accepts an optional
|
||||
# `head_commit_id` that would make that call a true compare-and-set; a PreToolUse hook cannot add an
|
||||
# argument, only refuse without one. With merge_when_checks_succeed, Gitea merges
|
||||
@@ -45,7 +45,8 @@
|
||||
# ETV_GITEA_URL overrides the base (default: the LAN instance; a LAN address, not a secret).
|
||||
set -euo pipefail
|
||||
|
||||
# THE FIRE-LOG PATH BELOW IS SELF-LOCATED, not `${CLAUDE_PROJECT_DIR:-...}` (ersatztv#858, #891).
|
||||
# THE FIRE-LOG PATH BELOW IS SELF-LOCATED, not `${CLAUDE_PROJECT_DIR:-...}` — as is every other
|
||||
# tracked hook's since ersatztv#891, byte-identically (`process.hook-resolves-inputs-from-repo-root`).
|
||||
# Written here rather than beside the assignment because the instrumentation preamble that follows is
|
||||
# machine-compared: `test_hook_fire_log.py::test_the_stripper_removes_EXACTLY_the_preamble_and_nothing_else`
|
||||
# permits only its own recognised lines in that block, so a comment inside it fails the suite.
|
||||
@@ -55,12 +56,6 @@ set -euo pipefail
|
||||
# MEASURED 2026-08-30: with the env-var-first form, a `hook-fire-log.sh` in an env-var-named tree
|
||||
# that prints an `allow` decision and exits 0 GRANTS THE MERGE outright, having bypassed every check
|
||||
# below. Self-locating binds it to the tree this hook was loaded from and closes that.
|
||||
#
|
||||
# The other twelve tracked hooks still carry the env-var-first form and are deliberately NOT changed
|
||||
# here; that sweep needs its own population and review (ersatztv#891, where the reachable case is
|
||||
# measured — husky launches the prepush hooks as `./.claude/hooks/...`, a RELATIVE path independent
|
||||
# of `$CLAUDE_PROJECT_DIR`, so the two roots genuinely diverge there). This copy is fixed because
|
||||
# leaving a total gate bypass 500 lines above the gate this PR hardens would make the rest decorative.
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
@@ -191,11 +186,11 @@ fi
|
||||
# posted before ersatztv#632 and gets NO opinion, rather than denying every in-flight PR the day
|
||||
# this lands. The window closes on its own — verdicts are per-head and short-lived, so every verdict
|
||||
# posted after this carries the field.
|
||||
# "Could not check" is a THIRD outcome, distinct from both "matches" and "no base recorded". Cold
|
||||
# review found the first draft collapsing it into the latter: an unreadable status response yielded
|
||||
# an empty `recorded_base`, which took the graceful-adoption path and skipped validation silently —
|
||||
# "Could not check" is a THIRD outcome, distinct from both "matches" and "no base recorded".
|
||||
# Collapsing it into the latter is a false-open: an unreadable status response yields
|
||||
# an empty `recorded_base`, which takes the graceful-adoption path and skips validation silently —
|
||||
# after which a later, successful status read could still auto-grant. A transient failure would then
|
||||
# have produced a "merge gate: satisfied" message for a comparison that never happened. Every
|
||||
# produce a "merge gate: satisfied" message for a comparison that never happened. Every
|
||||
# unreadable input here therefore falls through to a human (`ask`), never to silence.
|
||||
# RE-READ THE BASE HERE, ONCE, FOR EVERY PATH BELOW (ersatztv#778).
|
||||
#
|
||||
@@ -212,8 +207,8 @@ fi
|
||||
# of `process.check-and-use-pins-a-version`, so the guard enforcing that rule must not break it.
|
||||
#
|
||||
# This re-read first landed inside the scheduled-auto-merge branch only, which fixed the branch-
|
||||
# protection lookup and left the #632 retarget DETECTION below still reading the stale snapshot. Cold
|
||||
# review demonstrated the consequence with this repo's own fixture: scheduled+retarget denied, while
|
||||
# protection lookup and left the #632 retarget DETECTION below still reading the stale snapshot.
|
||||
# Measured on this repo's own fixture: scheduled+retarget denied, while
|
||||
# immediate+retarget auto-GRANTED. That is the twin-missed shape — a fix applied to the path where it
|
||||
# was noticed — so the re-read is hoisted above every consumer rather than duplicated into each.
|
||||
prjson_now=$(gq "repos/$owner/$repo/pulls/$pr")
|
||||
@@ -506,7 +501,7 @@ else
|
||||
# performs no matching and knows nothing about precedence, so a 200 from it means only "a rule
|
||||
# with this NAME exists and lists this context", never "this context is required on this branch".
|
||||
#
|
||||
# It was used first, with the list consulted only on a 404, and cold review found what that left
|
||||
# It was used first, with the list consulted only on a 404, and that design left a false-open
|
||||
# behind: the precedence argument below guarded the 404 path while the 200 path — the one this
|
||||
# repo actually takes — granted without it. Given a rule `main` requiring `review-verdict/h10` and
|
||||
# a rule `m*` with better Priority that does not, Gitea applies `m*`, and the by-name hit on
|
||||
@@ -590,8 +585,8 @@ else
|
||||
undecidable) decide ask "H6/H10 merge gate: no branch-protection rule on this repo governs '$base_ref' decidably — a GLOB rule could govern it, or two rule names fold-equal, or a name is non-ASCII. This hook deliberately does not reimplement Gitea's glob matcher, so whether 'review-verdict/h10' is required on this base cannot be derived here (ersatztv#778). Confirm it in the repo's branch-protection settings, or merge immediately instead of scheduling." ;;
|
||||
none) bp_code=nomatch; bp="" ;;
|
||||
# A DECLARED class of the classifier's contract (ersatztv#859), with its OWN sentinel — not
|
||||
# merely its own arm. The first draft gave it an arm that set `unreadable-rules`, the same value
|
||||
# the catch-all sets, and that arm was measured to be a no-op: deleting it left the WHOLE suite
|
||||
# merely its own arm. Giving it an arm that set `unreadable-rules`, the same value
|
||||
# the catch-all sets, was measured to be a no-op: deleting that arm left the WHOLE suite
|
||||
# green, because nothing downstream could tell the two apart. An arm no observation can
|
||||
# distinguish is not a fix, it is a comment with syntax. (The invariant is "no test reddens",
|
||||
# not a test count — a count goes stale the next time anyone adds one.)
|
||||
@@ -706,7 +701,7 @@ fi
|
||||
# sha taken from the first `@<hex>` anywhere on the line (a markdown link could supply it). Every
|
||||
# decision the classifier makes is documented there; this file only maps a class onto a hook decision.
|
||||
# RESOLVED FROM `$repo_root`, never `$CLAUDE_PROJECT_DIR` — the rule, the threat model and the
|
||||
# boundary are in `process.hook-resolves-inputs-from-repo-root` (ersatztv#858). Written once there
|
||||
# boundary are in `process.hook-resolves-inputs-from-repo-root` (ersatztv#858, #891). Written once there
|
||||
# rather than twice here: this file carried two resolutions of the same question, and the guard-scope
|
||||
# arm below is the other one. Two answers in one file is the state most likely to be "tidied" toward
|
||||
# the weaker side, so neither site restates the argument now.
|
||||
@@ -715,8 +710,8 @@ fi
|
||||
# would classify THIS PR's comments with THAT tree's copy of the H10 grammar.
|
||||
#
|
||||
# `ETV_HOOK_FIRE_LIB` at the top of this file is bound the same way, and for a STRONGER reason — it
|
||||
# is sourced, so it is code. See the block above it. The twelve other hooks still resolve it from
|
||||
# the env var and are ersatztv#891.
|
||||
# is sourced, so it is code. See the block above it. Since #891 every tracked hook binds it
|
||||
# identically, and `test_hook_fire_log.py` fails any that stops doing so.
|
||||
verdict_script="$repo_root/scripts/check-review-verdict.sh"
|
||||
if [ ! -x "$verdict_script" ]; then
|
||||
decide ask "H10 merge gate: verdict classifier not found at $verdict_script, so the review state can't be derived. Confirm the review covered the latest commit before merging."
|
||||
@@ -775,7 +770,7 @@ esac
|
||||
# `else` (mwcs = true) and never executes on an immediate merge, which is the common case; hanging
|
||||
# the freshness check off it would fire it only when an auto-merge is armed. This file already
|
||||
# records that exact defect one section up — the base re-read "first landed inside the
|
||||
# scheduled-auto-merge branch only", and cold review found scheduled+retarget denied while
|
||||
# scheduled-auto-merge branch only", with scheduled+retarget denied while
|
||||
# immediate+retarget auto-GRANTED. Same shape, so it is not repeated here.
|
||||
#
|
||||
# It reads `main` (the branch the snapshot names), NOT `$base_ref`. That is a DIFFERENT question
|
||||
|
||||
@@ -5,7 +5,7 @@ set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin pretooluse-nav-guard "" capture || true
|
||||
|
||||
@@ -11,7 +11,7 @@ set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin pretooluse-worktree-guard "" capture || true
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
export const meta = {
|
||||
name: 'ersatztv-issue-build',
|
||||
description: 'Close one ersatztv issue or bundle in its own worktree via PR: claim, recon, implement, local gate, adversarial review before the push, fix loop, single push, PR, closing record',
|
||||
phases: [{ title: 'Recon' }, { title: 'Implement' }, { title: 'Review' }, { title: 'Fix' }, { title: 'Land' }],
|
||||
}
|
||||
|
||||
// args: { issues: [n,...], slug, title, body_summary, done_condition, files_likely, area, size, risk: 'routine'|'rubric',
|
||||
// needs_e2e, port: the slot's ETV_UI_PORT, avoid: [{issues, files}], trailer: 'Co-Authored-By: ...\nClaude-Session: ...',
|
||||
// effort?: 'xhigh' for lock/threading/migration work, model?: override for the implementer/fixer }
|
||||
if (!args || !Array.isArray(args.issues) || !args.issues.length || !args.trailer || !/Claude-Session: \S+/.test(args.trailer) || !(Number.isInteger(args.port) && args.port > 1024 && args.port < 65000)) {
|
||||
return { error: 'args.issues (non-empty), args.trailer (with a Claude-Session: line) and an integer args.port in (1024, 65000) are required' }
|
||||
}
|
||||
const issues = args.issues
|
||||
const ISSUE = issues[0]
|
||||
const REF = issues.map(n => '#' + n).join(', ')
|
||||
const BRANCH = `${issues.join('-')}-${(args.slug || 'work')}`
|
||||
const WT = `/Users/timothy/orca/workspaces/ersatztv/wt-${issues.join('-')}`
|
||||
const SHARED = '/Users/timothy/ersatztv'
|
||||
const API = 'http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv'
|
||||
const big = args.size === 'large'
|
||||
const rubric = args.risk === 'rubric'
|
||||
const implModel = args.model || (args.size === 'small' ? 'sonnet' : 'opus')
|
||||
const implEffort = args.effort || (args.size === 'small' ? 'medium' : 'high')
|
||||
const TRAILER = args.trailer
|
||||
const SESSION_URL = TRAILER.split('\n').filter(l => l.startsWith('Claude-Session:')).map(l => l.replace('Claude-Session: ', '')).join('\n')
|
||||
|
||||
const COMMON = `Project: ersatztv, a fork of the ErsatzTV IPTV channel server (C#/.NET + a React SPA under web/). Shared checkout ${SHARED} is READ-ONLY for you: never commit there and never read its git log or HEAD as truth about main (process.shared-tree-readonly) — origin/main after a fetch is the only truth.
|
||||
Issue(s) ${REF}: "${args.title}".
|
||||
Issue body (condensed by a picker; read the real thing): ${args.body_summary}
|
||||
DONE CONDITION: ${args.done_condition}
|
||||
Read every issue in the bundle and all its comments yourself: curl -s -u "$ETV_GITEA_BASICAUTH" ${API}/issues/${ISSUE} and ${API}/issues/${ISSUE}/comments (the env var is set; never write the credential into a file or a commit).
|
||||
|
||||
Other slots of this session are working IN PARALLEL and will edit these files; do not touch them, and if your fix genuinely needs one of them, stop and report it instead of editing:
|
||||
${JSON.stringify(args.avoid || [], null, 1)}
|
||||
|
||||
Working rules, non-negotiable:
|
||||
- Docs-first is a HARD RULE: read CLAUDE.md, then docs/README.md's task-signal map and ONLY the sections it points to for this task, then docs/contributing.md for the code you touch. Decisions resolve through docs/decisions/README.md by key, never by chasing a file path named in an old comment. Do not reverse-engineer conventions from source before reading these.
|
||||
- Docs-update is part of done, same PR: an endpoint change updates docs/api-conventions.md's checklist and regenerates v1.json + endpoint-index.md via ./scripts/update-openapi.sh (build the app project first, then the script, then npm run generate:api under web/); a screen or route change updates docs/blazor-route-parity.md + docs/domain-model.md; a new or reversed convention gets a record under docs/decisions/records/<area>/ and a regenerated catalog (PYTHONPATH=. python3 scripts/build_decisions_catalog.py — the catalog docs/decisions/README.md is generated and shared with other slots: never hand-edit it, regenerate it, and resolve a rebase conflict in it by regenerating); a new or retitled doc updates docs/README.md.
|
||||
- A TvContext model change needs a migration in BOTH providers: scripts/add-migration.sh <Name>.
|
||||
- Tests are NUnit + Shouldly + NSubstitute in the existing *.Tests projects; vitest under web/. Pin the behaviour with a test that reddens when the fix alone is removed; never set ETV_UPDATE_GOLDENS or ETV_UPDATE_PLAYOUT_GOLDENS.
|
||||
- Dependencies use Central Package Management: versions live only in Directory.Packages.props.
|
||||
- Docs record the end state, never the investigation (docs.no-session-narrative): the path goes in the commit message and the issue comment. Date any measurement you write into a doc.
|
||||
- Gitea labels take their own endpoint: POST ${API}/issues/{n}/labels {"labels":[100]} adds in-progress, DELETE ${API}/issues/{n}/labels/100 removes it; PATCH silently ignores labels.
|
||||
- Never use bare git stash (the stash stack is shared across worktrees; commit WIP instead). Never push to main (it is refused server-side anyway). Never amend or force-push a pushed branch; a fix after the push is a new commit. Never cd out of your worktree except to read the shared checkout read-only.
|
||||
- Kill only PIDs you started; never pkill by name — other sessions run dotnet and Playwright on this machine.`
|
||||
|
||||
const WORKTREE = `Worktree: ${WT} on branch ${BRANCH}. Check git -C ${SHARED} worktree list; if absent: git -C ${SHARED} fetch origin && git -C ${SHARED} worktree add ${WT} -b ${BRANCH} origin/main (absolute path, as written). Then give it its own web/node_modules: if cmp -s ${SHARED}/web/package-lock.json ${WT}/web/package-lock.json then cp -Rc ${SHARED}/web/node_modules ${WT}/web/node_modules, else (cd ${WT}/web && npm ci). Do ALL work inside ${WT}. If git commit is denied by the worktree-owner guard, the worktree belongs to ANOTHER session (orchestrated worktrees carry no marker): never overwrite the marker — STOP and report done=false with the guard's message. Commit as you go; every commit message ends with these trailer lines exactly:
|
||||
${TRAILER}`
|
||||
|
||||
const CLAIM = `CLAIM FIRST, the four-way check from the kickoff (process.parallel-session-claim), for EVERY issue in the bundle: git -C ${SHARED} fetch origin; curl the open PRs (${API}/pulls?state=open&limit=50, page until empty) for a body saying fixes/refs ${REF}; ${issues.map(n => `git -C ${SHARED} ls-remote --heads origin '*${n}*'`).join('; ')}; read each issue's comments for a claim that predates the label. If a PR, branch or comment shows another session already on ${REF} (other than this orchestrator's note, if any), STOP and report done=false with the evidence. Otherwise add the in-progress label and post a claiming comment naming branch ${BRANCH} and worktree ${WT}, on every issue in the bundle. If an issue body has no "## Done-when" section, append one (PATCH ${API}/issues/{n} with the full body): one unticked box per concrete completion criterion drawn from the issue, plus "- [ ] Adversarial review passed". The merge gate derives consent from those boxes; the orchestrator ticks them from your evidence, so write criteria that can be evidenced.`
|
||||
|
||||
const gateFor = (port, where) => `LOCAL GATE (process.local-gate-before-push) — run it inside ${where} and read the real output; a skipped test is not a passing one:
|
||||
- .NET: dotnet build the solution, then dotnet test on every test project that covers what you touched (ErsatzTV.Tests, ErsatzTV.Core.Tests, ErsatzTV.Scanner.Tests, ErsatzTV.FFmpeg.Tests, ErsatzTV.Architecture.Tests — all of them for anything under ErsatzTV.Core). Before any push touching .cs: BOM-check the touched set with od -A n -t x1 -N 3 <file> (efbbbf = BOM) and run bash -c 'dotnet format whitespace . --folder --verify-no-changes --include <files>' (process.bom-format-detection-recipe).
|
||||
- SPA: cd web && npm run check:api && npm run lint && npm run typecheck && npm run build && npm test.
|
||||
- scripts/, .claude/, .husky/, .gitea/: PYTHONPATH=. python3 -m pytest scripts/tests -q, plus ruff check and ruff format --check on any Python you touched. A new executable under scripts/ or .claude/hooks/ needs its row in docs/remote-state-inventory.md and, if it is a guard, in docs/guard-inventory.md — the suites say so.
|
||||
- Docs: python3 scripts/check-doc-narrative.py --diff origin/main and answer what it flags (it is advisory, the rule is not).
|
||||
- Live-E2E${args.needs_e2e ? ' IS REQUIRED for this change (write path or UI)' : ' only if you changed a write path or a screen'}: ETV_UI_PORT=${port} scripts/e2e-local.sh <fresh CONFIG_DIR> — port ${port} is yours; one run at a time in that worktree; curl the endpoints, never a browser tab; when done, kill the PID the launcher printed and nothing else. The launcher's pre-flight refuses a busy port and names the holder: report that, do not pick another port and never kill the holder.
|
||||
- Builds on this Mac are capped at 3–4 concurrent and other slots are building too: run the .NET and web gates sequentially, not in parallel with each other.`
|
||||
const GATE = gateFor(args.port, WT)
|
||||
|
||||
const REPORT_SCHEMA = {
|
||||
type: 'object',
|
||||
required: ['done', 'summary', 'verified', 'left', 'commits', 'head_sha'],
|
||||
properties: {
|
||||
done: { type: 'boolean' },
|
||||
summary: { type: 'string', description: 'what was built, file by file' },
|
||||
verified: { type: 'string', description: 'exact gate commands run and their real output summary (test counts, E2E result)' },
|
||||
left: { type: 'string', description: 'what is not done and why; what the next agent must know' },
|
||||
commits: { type: 'string', description: 'git log --oneline origin/main..HEAD' },
|
||||
pr_url: { type: 'string' },
|
||||
head_sha: { type: 'string', description: 'git rev-parse HEAD of YOUR WORKTREE after your last commit (not a PR head) — the finisher derives fix commits from these' },
|
||||
patch_changed: { type: 'boolean', description: 'finisher only: true if the pre-push rebase changed the patch-id (a conflict resolved or an artifact regenerated)' },
|
||||
},
|
||||
}
|
||||
const FINDINGS_SCHEMA = {
|
||||
type: 'object', required: ['findings', 'verdict'],
|
||||
properties: {
|
||||
verdict: { type: 'string', enum: ['merge', 'send-back'] },
|
||||
findings: { type: 'array', items: { type: 'object', required: ['severity', 'file', 'summary', 'evidence'], properties: {
|
||||
severity: { type: 'string', enum: ['blocking', 'should-fix', 'nit'] }, file: { type: 'string' }, summary: { type: 'string' }, evidence: { type: 'string' } } } },
|
||||
},
|
||||
}
|
||||
const RUNNER_SCHEMA = {
|
||||
type: 'object', required: ['findings', 'verdict', 'ran'],
|
||||
properties: {
|
||||
ran: { type: 'boolean', description: 'false if codex produced no VERDICT line — required, because the fallback branches on it' },
|
||||
verdict: FINDINGS_SCHEMA.properties.verdict, findings: FINDINGS_SCHEMA.properties.findings,
|
||||
},
|
||||
}
|
||||
const LAND_SCHEMA = {
|
||||
type: 'object', required: REPORT_SCHEMA.required.concat(['patch_changed']),
|
||||
properties: REPORT_SCHEMA.properties,
|
||||
}
|
||||
const RECON_SCHEMA = {
|
||||
type: 'object', required: ['plan', 'facts', 'risks', 'test_plan'],
|
||||
properties: {
|
||||
plan: { type: 'string', description: 'files, handlers, components, signatures, exact edits' },
|
||||
facts: { type: 'string', description: 'what the docs the task-signal map names and the existing code say, with paths and decision keys' },
|
||||
risks: { type: 'string' }, test_plan: { type: 'string', description: 'tests to add and the gate or E2E route that proves the done condition' },
|
||||
},
|
||||
}
|
||||
|
||||
let recon = null
|
||||
if (big) {
|
||||
phase('Recon')
|
||||
recon = await agent(`${COMMON}
|
||||
|
||||
You are the recon agent. Read-only, in ${SHARED}. Read the docs the task-signal map names for this task, then find every fact an implementer needs to close ${REF} without re-deriving it: the exact handlers, components, signatures, call sites and guards, the existing tests, and which gate or E2E route proves the done condition. For a multi-site sweep use the csharp-lsp MCP tools, not the LSP tool (docs/local-lsp-tooling.md). Produce a concrete plan.`,
|
||||
{ label: 'recon', model: 'opus', effort: 'high', schema: RECON_SCHEMA })
|
||||
}
|
||||
|
||||
phase('Implement')
|
||||
const impl = await agent(`${COMMON}
|
||||
|
||||
${WORKTREE}
|
||||
|
||||
${CLAIM}
|
||||
|
||||
${recon ? `Recon (verify what you rely on):\nPLAN: ${recon.plan}\nFACTS: ${recon.facts}\nRISKS: ${recon.risks}\nTEST PLAN: ${recon.test_plan}\n` : ''}
|
||||
You are the implementer. Close ${REF} completely: pin the behaviour with tests named for the branch they protect, update the docs the change obligates, commit. Then git fetch origin and rebase onto origin/main if it moved (never merge main in; regenerate generated artifacts), run the LOCAL GATE and STOP — do not push; reviewers read your worktree first, and a finisher pushes once after the review loop is clean. ${GATE}
|
||||
Report done=true with the gate output when the worktree is ready for review, with pr_url empty and head_sha = git rev-parse HEAD of the worktree after your last commit.`,
|
||||
{ label: `impl:${REF}`, model: implModel, effort: implEffort, schema: REPORT_SCHEMA })
|
||||
if (!impl) return { issues, error: 'implementer returned nothing' }
|
||||
if (!impl.done) return { issues, error: 'implementer stopped', impl }
|
||||
|
||||
const reviewCommon = (e2ePort) => `${COMMON}
|
||||
|
||||
${gateFor(e2ePort, 'your own isolated worktree (never ' + WT + ')')}
|
||||
|
||||
Worktree ${WT}, branch ${BRANCH}, not yet pushed; diff: git -C ${WT} diff origin/main...HEAD. Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or run tests, do it in your own isolated worktree, never in ${WT}: git fetch ${WT} ${BRANCH} && git checkout --detach FETCH_HEAD puts the unpushed branch there; run the .NET and web gates sequentially — other slots are building; E2E there on port ${e2ePort} (the GATE above is written for your worktree and that port).`
|
||||
|
||||
const LENSES = [
|
||||
{ key: 'correctness', model: 'opus', isolation: 'worktree', prompt: 'correctness against the done condition: run the gate and, for a write path or screen, the live-E2E route yourself, and read the output; try to break the change with the edge cases the issue and the docs name; check the pinning test actually reddens when the fix alone is reverted (mutate the clause, not the file).' },
|
||||
{ key: 'conformance', model: 'sonnet', prompt: 'repo conformance: docs-update obligations met in this diff (endpoint → api-conventions + regenerated v1.json/endpoint-index; screen/route → blazor-route-parity + domain-model; convention → decision record + regenerated catalog; new doc → README index); no narrative in docs; every new script or hook has its inventory row; CPM respected; both-provider migration if the model changed; tests are NUnit/vitest in the existing projects; no BOM in touched .cs; no edit to a file another slot owns (listed above); commit trailers present; branch rebased on current origin/main; nothing pushed yet.' },
|
||||
]
|
||||
let xfamilyFailedRound = null
|
||||
let xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)'
|
||||
async function codexRunner(round) {
|
||||
const r = await agent(`${reviewCommon(Number(args.port) + 3)}
|
||||
|
||||
You run the cross-family review — the diff touches a class where process.independent-review-rubric requires a reviewer from another model family, and you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of the diff of branch ${BRANCH} against origin/main in ${WT} for issue(s) ${REF} with done condition "${args.done_condition}", listing findings as blocking / should-fix / nit with file and evidence, ending with a line VERDICT: merge or VERDICT: send-back. Run it EXACTLY like this, in the background, output to a file, stdin from /dev/null (it hangs otherwise): codex exec -C ${WT} -s read-only "$(cat <prompt>)" < /dev/null > <out> 2>&1 — then wait for the process to exit (poll pgrep on its PID with Monitor; measured 2026-07-28 in the #672 session, a real review took ~35 minutes for a 7-file diff) and read the file. Return its findings faithfully in the schema with ran=true; if the file has no VERDICT line the run failed (quota, tool error) — return ran=false, verdict merge, no findings, and put the file's tail in a single nit finding so the failure is visible; never invent a verdict.`,
|
||||
{ label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: RUNNER_SCHEMA })
|
||||
return r
|
||||
}
|
||||
async function codexFallback(round, r) {
|
||||
xfamily = `codex could not run in round ${round} (${r ? 'no VERDICT line' : 'runner returned nothing'}); substituted a cold same-family review-only agent per process.independent-review-rubric — retry cross-family next window`
|
||||
log(`${REF}: ${xfamily}`)
|
||||
return agent(`${reviewCommon(Number(args.port) + 2)}
|
||||
|
||||
You are a COLD, review-only substitute for a cross-family reviewer that could not run. You have seen none of this branch before. Lens: adversarial correctness AND security of the diff against the done condition — the classes process.independent-review-rubric names (locks/concurrency, auth/security, API write paths, migrations, large C# diffs). Run the gate in your own worktree and read the output; report only what you verified, with evidence. blocking = done condition or a repo rule violated; should-fix = real defect; nit = style. Verdict send-back if any blocking.`,
|
||||
{ label: `review:fallback:r${round}`, phase: 'Review', model: 'opus', effort: 'high', isolation: 'worktree', schema: FINDINGS_SCHEMA })
|
||||
}
|
||||
async function review(round) {
|
||||
// Per round, like blocking/sendBack: a substitute that failed in round 1 says nothing about the tree
|
||||
// that lands after round 2, and a stale xfamily string must never reach the PR body.
|
||||
xfamilyFailedRound = null
|
||||
xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)'
|
||||
// The Codex runner builds nothing, so it may run beside the lenses; the FALLBACK is a second
|
||||
// worktree-isolated .NET reviewer and starts only after both lenses have returned.
|
||||
const runnerPromise = rubric ? codexRunner(round).catch(() => null) : Promise.resolve(null)
|
||||
const lenses = (await parallel(LENSES.map(l => () => agent(`${reviewCommon(Number(args.port) + 1)}
|
||||
|
||||
Review round ${round} of the branch for ${REF}. Lens: ${l.prompt}
|
||||
Be adversarial; report only what you verified, with evidence. blocking = done condition or a repo rule violated, or a test that passes for the wrong reason; should-fix = real defect; nit = style. Verdict send-back if any blocking.`,
|
||||
{ label: `review:${l.key}:r${round}`, phase: 'Review', model: l.model, effort: 'high', isolation: l.isolation, schema: FINDINGS_SCHEMA })))).filter(Boolean)
|
||||
if (!rubric) return lenses
|
||||
const r = await runnerPromise
|
||||
if (r && r.ran === true) return lenses.concat([r])
|
||||
let fb = null
|
||||
try { fb = await codexFallback(round, r) } catch (e) { log(`${REF}: fallback reviewer threw: ${e && e.message}`) }
|
||||
if (!fb) { xfamily += ` — the substitute ALSO failed in round ${round}; no cross-family-equivalent review ran`; xfamilyFailedRound = round }
|
||||
return fb ? lenses.concat([fb]) : lenses
|
||||
}
|
||||
|
||||
let round = 1
|
||||
const actionable = rs => rs.flatMap(r => r.findings.filter(f => f.severity === 'blocking' || f.severity === 'should-fix'))
|
||||
const countBy = (rs, sev) => rs.flatMap(r => r.findings).filter(f => f.severity === sev).length
|
||||
let knownHead = impl.head_sha
|
||||
let reviews = (await review(round)).filter(Boolean)
|
||||
if (!reviews.length) return { issues, error: `review round ${round} produced no reviews (every lens failed); not pushed`, history: [] }
|
||||
let blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking'))
|
||||
let sendBack = actionable(reviews)
|
||||
const history = [{ round, reviews, fix: null, fix_range: null }]
|
||||
while (sendBack.length && round < 3) {
|
||||
log(`${REF} round ${round}: ${blocking.length} blocking, ${sendBack.length - blocking.length} should-fix — sending back`)
|
||||
const fix = await agent(`${COMMON}
|
||||
|
||||
${WORKTREE}
|
||||
|
||||
You are the fixer. Reviewers found these problems in the unpushed branch; fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong:
|
||||
${JSON.stringify(reviews.flatMap(r => r.findings.filter(f => f.severity !== 'nit')), null, 1)}
|
||||
Then re-run the LOCAL GATE and STOP without pushing; the reviewers read the worktree again. ${GATE}
|
||||
Report, with head_sha = git rev-parse HEAD of the worktree after your last commit.`,
|
||||
{ label: `fix:r${round}`, phase: 'Fix', model: implModel, effort: implEffort, schema: REPORT_SCHEMA })
|
||||
if (!fix || !fix.done) return { issues, error: `fixer for round ${round} ${fix ? 'stopped' : 'returned nothing'}; not pushed`, fix, history }
|
||||
history[history.length - 1].fix = fix
|
||||
history[history.length - 1].fix_range = fix.head_sha && fix.head_sha !== knownHead ? `${knownHead}..${fix.head_sha}` : null
|
||||
knownHead = fix.head_sha || knownHead
|
||||
round++
|
||||
reviews = (await review(round)).filter(Boolean)
|
||||
if (!reviews.length) return { issues, error: `review round ${round} produced no reviews (every lens failed); not pushed`, history }
|
||||
blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking'))
|
||||
sendBack = actionable(reviews)
|
||||
history.push({ round, reviews, fix: null, fix_range: null })
|
||||
}
|
||||
if (blocking.length) return { issues, error: 'blocking findings after two fix rounds; not pushed', blocking_remaining: blocking, history }
|
||||
if (sendBack.length) return { issues, error: 'should-fix findings still open after two fix rounds; not pushed — the orchestrator decides', should_fix_remaining: sendBack, history }
|
||||
if (xfamilyFailedRound) return { issues, error: `the cross-family runner and its substitute both failed in round ${xfamilyFailedRound}; not pushed`, cross_family: xfamily, history }
|
||||
const FIX_RANGES = history.map(h => h.fix_range).filter(Boolean)
|
||||
const REVIEW_HISTORY = history.map(h => `round ${h.round}: ${h.reviews.length} lens(es); ${countBy(h.reviews, 'blocking')} blocking, ${countBy(h.reviews, 'should-fix')} should-fix, ${countBy(h.reviews, 'nit')} nit` + (h.fix ? (h.fix_range ? `; answered by the fix commit(s) in git log --oneline ${h.fix_range}` : '; answered without a new commit (findings refuted with evidence in the fixer report)') : '; clean — loop ended')).join('\n')
|
||||
|
||||
phase('Land')
|
||||
const FINISH = `FINISH, in this order. Record the patch-id first: git diff $(git merge-base origin/main HEAD)..HEAD | git patch-id --stable. Then git fetch origin; if origin/main moved, rebase onto it (never merge main in; regenerate, never hand-resolve, generated artifacts — the decisions catalog by its generator), re-run the LOCAL GATE, and recompute the patch-id: report patch_changed=true if it differs. ${GATE}
|
||||
Then ONE push: git push -u origin ${BRANCH}. Open the PR with the Gitea API (POST ${API}/pulls; head=${BRANCH}, base=main, title, body). The body must contain "fixes #N" for every issue in the bundle so the merge closes them, the root cause for a bug fix, the measured numbers, the review history VERBATIM as recorded by the workflow, one line per round, between the markers <<REVIEW HISTORY and REVIEW HISTORY>>:
|
||||
<<REVIEW HISTORY
|
||||
${REVIEW_HISTORY}
|
||||
REVIEW HISTORY>>
|
||||
${FIX_RANGES.length ? `followed by what each fix commit changed, read from git show and not from memory, for exactly the commits git log --oneline lists in these ranges: ${FIX_RANGES.join('; ')}` : (history.some(h => h.fix) ? 'and a sentence saying every finding was answered without a new commit, as the history block records' : 'and a sentence saying no fix commit exists because round one was clean')}, then the cross-family review status verbatim — "${xfamily}" — and every deliberately-left item with an issue number (file follow-up issues where needed). End the body with:
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
|
||||
${SESSION_URL}
|
||||
Arm the CI monitor: note the head sha and read ${API}/commits/<sha>/status once. Then the closing-an-issue skill (invoke it through the Skill tool if you have it, otherwise read .claude/skills/closing-an-issue/SKILL.md) with two modifications: do NOT close the issue — the merge closes it — and do NOT tick any "## Done-when" box; instead the "## Closing record" comment you post on each issue, linking the PR, ends with a "Done-when evidence" list giving, for every box, the command or artifact that evidences it — the orchestrator ticks from that. Remove nothing; the orchestrator removes the worktree after the merge. Report the PR URL, the head sha and patch_changed.`
|
||||
const land = await agent(`${COMMON}
|
||||
|
||||
${WORKTREE}
|
||||
|
||||
You are the finisher. The branch has passed its review loop (${round} round(s)); nothing is pushed yet. ${FINISH}`,
|
||||
{ label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: LAND_SCHEMA })
|
||||
if (!land || !land.done) return { issues, error: 'finisher stopped', land, history }
|
||||
if (!land.pr_url || !land.head_sha) return { issues, error: 'finisher reported done without a PR URL or head sha — the branch may already be pushed; read its report before re-running', land, history }
|
||||
log(`${REF} PR: ${land.pr_url || 'none'} @ ${land.head_sha || '?'}${land.patch_changed ? ' (patch changed by the pre-push rebase)' : ''}`)
|
||||
let post_rebase_reviews = null
|
||||
if (land.patch_changed) {
|
||||
log(`${REF}: patch changed on rebase — one more review round on the pushed head before any verdict`)
|
||||
round++
|
||||
post_rebase_reviews = (await review(round)).filter(Boolean)
|
||||
if (!post_rebase_reviews.length) return { issues, error: 'the post-rebase review round produced no reviews (every lens failed); pushed, no verdict may be posted', pr_url: land.pr_url, head_sha: land.head_sha, cross_family: xfamily, history }
|
||||
const late = actionable(post_rebase_reviews)
|
||||
if (late.length) return { issues, error: 'blocking or should-fix findings on the pushed head after the pre-push rebase; no verdict may be posted', pr_url: land.pr_url, head_sha: land.head_sha, findings_remaining: late, cross_family: xfamily, history, post_rebase_reviews }
|
||||
}
|
||||
return { issues, pr_url: land.pr_url, head_sha: land.head_sha, patch_changed: !!land.patch_changed, cross_family: xfamily, impl, land, history, post_rebase_reviews }
|
||||
@@ -0,0 +1,52 @@
|
||||
export const meta = {
|
||||
name: 'ersatztv-pick-next',
|
||||
description: 'Pick the next N ersatztv issues by the kickoff queue rules from scripts/select-queue.sh and live Gitea state, mutually non-colliding and avoiding what other slots hold, then adversarially verify the set',
|
||||
phases: [{ title: 'Pick' }, { title: 'Refute' }],
|
||||
}
|
||||
// args: { taken: [{issues:[n], files:[...]}], closed: [n...], notes: 'free text', count: how many picks to return (default 3) }
|
||||
const taken = (args && args.taken) || []
|
||||
const closed = (args && args.closed) || []
|
||||
const notes = (args && args.notes) || ''
|
||||
const count = (args && args.count) || 3
|
||||
const RULES = `Work read-only in /Users/timothy/ersatztv (the shared checkout; do not modify files, push, label or comment). Never read its git log or HEAD as truth about main: run git -C /Users/timothy/ersatztv fetch origin first, then read origin/main.
|
||||
Read docs/handoffs/chicorytv-issue-queue.md fully — "Current phase", "Two concurrent tracks", the Selection and Bundles rules, and step 3's four-way claim check — and docs/handoffs/orchestration.md.
|
||||
Ranking is NOT yours to derive: run ETV_GITEA_BASICAUTH="$ETV_GITEA_BASICAUTH" scripts/select-queue.sh 40 (the env var is already set) and take its order as given. It already excludes in-progress, parked, PRs, bot-authored issues and anything with an open blocker. Resolve only its CLAIM? and UMBRELLA? flags, by reading the flagged issue's body and comments.
|
||||
Gitea REST: base http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv, auth -u "$ETV_GITEA_BASICAUTH", curl only. Issue: GET /issues/{n}; comments: GET /issues/{n}/comments; open PRs: GET /pulls?state=open&limit=50 (page until a page comes back empty — the endpoint caps limit at 50). Remote branches naming an issue: git -C /Users/timothy/ersatztv ls-remote --heads origin '*<n>*'.
|
||||
A pick is claimable only if the four-way check is clean: no open PR whose body says fixes/refs #n, no remote branch naming n, no claiming comment on the issue (a claim can precede the label), and the issue is still open after the fetch.
|
||||
Bundles: after choosing an issue, scan its milestone, its cross-references and its labels for small independent siblings that are cheap to sweep in the same worktree; a bundle is one pick with several issue numbers. Never bundle issues that a taken slot already holds.
|
||||
ALREADY TAKEN by this orchestrator (in flight, with the files each edits): ${JSON.stringify(taken)}
|
||||
Closed this session: ${JSON.stringify(closed)}
|
||||
Orchestrator notes: ${notes}`
|
||||
const PICK = { type: 'object', required: ['issues', 'title', 'slug', 'rationale', 'body_summary', 'done_condition', 'files_likely', 'area', 'size', 'risk', 'needs_e2e', 'skipped'], properties: {
|
||||
issues: { type: 'array', items: { type: 'integer' } }, title: { type: 'string' },
|
||||
slug: { type: 'string', description: 'short kebab-case branch slug, e.g. null-font-family' },
|
||||
rationale: { type: 'string' },
|
||||
body_summary: { type: 'string', description: 'body plus all comments, condensed but complete; include the Done-when section verbatim if the issue has one' },
|
||||
done_condition: { type: 'string' },
|
||||
files_likely: { type: 'array', items: { type: 'string' } },
|
||||
area: { type: 'string', enum: ['spa', 'api', 'core', 'scanner', 'ffmpeg', 'ci', 'scripts', 'docs', 'mixed'] },
|
||||
size: { type: 'string', enum: ['small', 'medium', 'large'] },
|
||||
risk: { type: 'string', enum: ['routine', 'rubric'], description: 'rubric = touches locks/concurrency, auth/security, an API write-path handler, a DB migration, or will exceed ~150 changed C# lines (process.independent-review-rubric); needs a cross-family review' },
|
||||
needs_e2e: { type: 'boolean', description: 'true for a write path or UI change (testing.live-e2e-prepush-timing)' },
|
||||
skipped: { type: 'string', description: 'each higher-ranked issue skipped and the reason' } } }
|
||||
const SCHEMA = { type: 'object', required: ['picks'], properties: { picks: { type: 'array', items: PICK, description: 'in queue order; each later pick avoids the files of every earlier one' } } }
|
||||
const VERDICT = { type: 'object', required: ['refuted', 'reason'], properties: { refuted: { type: 'boolean' }, reason: { type: 'string' }, bad_picks: { type: 'array', items: { type: 'integer' }, description: 'issue numbers of the picks that fail, if not all' }, better: { type: 'array', items: { type: 'integer' } } } }
|
||||
phase('Pick')
|
||||
const res = await agent(`${RULES}
|
||||
|
||||
Walk the selector's order and return up to ${count} issues or natural bundles, in that order, each of which (a) passes the four-way claim check, (b) edits no file a taken slot OR AN EARLIER PICK edits, (c) does not depend on another open issue (an earlier pick counts as open; a blocked-by dependency the selector already dropped), (d) is not a screen, handler or script an earlier pick is already on, (e) is not needs-hands or needs-the-user in disguise (a live-prod measurement nobody can take from here, a design question the body leaves open). Read each candidate's body and comments before accepting or rejecting it. Size is not a reason to skip: a large issue at the top of the queue is a pick, say size=large. Classify risk honestly — a write-path handler is rubric even when the diff is small. Stop early if the eligible queue runs out and say so in the last pick's skipped field; fewer than ${count} is fine, a colliding pair is not.`, { label: 'picker', model: 'sonnet', effort: 'medium', schema: SCHEMA })
|
||||
const picks = (res && res.picks) || []
|
||||
if (!picks.length) return { picks: [], refutations: [], note: 'the picker returned no eligible pick', raw: res }
|
||||
log('picks: ' + picks.map(p => '#' + p.issues.join('+#')).join(', '))
|
||||
phase('Refute')
|
||||
const desc = picks.map(p => `- #${p.issues.join(', #')} "${p.title}" (size ${p.size}, risk ${p.risk}, area ${p.area}, e2e ${p.needs_e2e}). Rationale: ${p.rationale}. Files: ${p.files_likely.join(', ')}. Skipped: ${p.skipped}`).join('\n')
|
||||
const votes = await parallel([
|
||||
'ordering and claims: re-run scripts/select-queue.sh and the four-way claim check on every pick; refute if a higher-ranked eligible issue was skipped without a valid reason, the picks are out of selector order, or a pick is already claimed by a PR, branch or comment',
|
||||
'collisions and classification: read the code each pick will touch; refute if any pick edits a file a taken slot or another pick edits, or the same docs section, or depends on an open issue; also refute a risk=routine pick that touches a lock, auth, an API write-path handler or a migration, and a needs_e2e=false pick that changes a write path or a screen',
|
||||
].map((lens, i) => () =>
|
||||
agent(`${RULES}
|
||||
|
||||
Picks, in order:
|
||||
${desc}
|
||||
Lens: ${lens}. Try to refute; name the failing picks in bad_picks and a better ordering in better.`, { label: `refute:${i}`, model: 'sonnet', effort: 'medium', schema: VERDICT })))
|
||||
return { picks, refutations: votes.filter(Boolean).filter(v => v.refuted) }
|
||||
@@ -0,0 +1,204 @@
|
||||
export const meta = {
|
||||
name: 'ersatztv-resume-branch',
|
||||
description: 'Resume a paused ersatztv branch: finish or fix, rebase onto origin/main, local gate, adversarial review, fix loop, push, PR body and closing record refreshed',
|
||||
phases: [{ title: 'Work' }, { title: 'Review' }, { title: 'Fix' }, { title: 'Land' }],
|
||||
}
|
||||
|
||||
// args: { issues, branch, wt, pr (number or ''), mode: 'fix'|'implement', title, risk: 'routine'|'rubric', needs_e2e,
|
||||
// port: the slot's ETV_UI_PORT, trailer, brief: path to a JSON file holding done_condition, findings, recon, context }
|
||||
if (!args || !Array.isArray(args.issues) || !args.issues.length || !args.trailer || !/Claude-Session: \S+/.test(args.trailer) || !(Number.isInteger(args.port) && args.port > 1024 && args.port < 65000) || !args.wt || !args.branch || !args.brief) {
|
||||
return { error: 'args.issues (non-empty), trailer (with a Claude-Session: line), an integer port in (1024, 65000), wt, branch and brief are required' }
|
||||
}
|
||||
const issues = args.issues
|
||||
const REF = issues.map(n => '#' + n).join(', ')
|
||||
const WT = args.wt
|
||||
const BRANCH = args.branch
|
||||
const SHARED = '/Users/timothy/ersatztv'
|
||||
const API = 'http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv'
|
||||
const rubric = args.risk === 'rubric'
|
||||
const TRAILER = args.trailer
|
||||
const SESSION_URL = TRAILER.split('\n').filter(l => l.startsWith('Claude-Session:')).map(l => l.replace('Claude-Session: ', '')).join('\n')
|
||||
|
||||
const COMMON = `Project: ersatztv, a fork of the ErsatzTV IPTV channel server (C#/.NET + a React SPA under web/). Shared checkout ${SHARED} is READ-ONLY for you: never commit there and never read its git log or HEAD as truth about main (process.shared-tree-readonly) — origin/main after a fetch is the only truth.
|
||||
Issue(s) ${REF}: "${args.title}".
|
||||
YOUR BRIEF is the JSON file ${args.brief}: read it first with cat. It holds done_condition, context from the orchestrator, findings (the last review round) and recon where they apply.
|
||||
Read every issue in the bundle and all its comments: curl -s -u "$ETV_GITEA_BASICAUTH" ${API}/issues/N and ${API}/issues/N/comments (the env var is set; never write the credential into a file or a commit).
|
||||
|
||||
Working rules, non-negotiable:
|
||||
- Docs-first is a HARD RULE: read CLAUDE.md, then docs/README.md's task-signal map and ONLY the sections it points to for this task, then docs/contributing.md for the code you touch. Decisions resolve through docs/decisions/README.md by key.
|
||||
- Docs-update is part of done, same PR: an endpoint change updates docs/api-conventions.md's checklist and regenerates v1.json + endpoint-index.md via ./scripts/update-openapi.sh (build the app project first, then the script, then npm run generate:api under web/); a screen or route change updates docs/blazor-route-parity.md + docs/domain-model.md; a convention gets a record under docs/decisions/records/<area>/ and a regenerated catalog (PYTHONPATH=. python3 scripts/build_decisions_catalog.py — the catalog is generated and shared with other slots: never hand-edit it, regenerate it, and resolve a rebase conflict in it by regenerating); a new doc updates docs/README.md. A TvContext change needs both providers' migrations via scripts/add-migration.sh.
|
||||
- Tests are NUnit + Shouldly + NSubstitute; vitest under web/. Never set ETV_UPDATE_GOLDENS or ETV_UPDATE_PLAYOUT_GOLDENS. Dependencies only in Directory.Packages.props.
|
||||
- Docs record the end state, never the investigation; the path goes in the commit message.
|
||||
- Gitea labels: POST ${API}/issues/{n}/labels {"labels":[100]} / DELETE ${API}/issues/{n}/labels/100; PATCH ignores labels.
|
||||
- Never use bare git stash. Never push to main. The ONLY sanctioned rewrite of a pushed branch is a rebase onto origin/main pushed with --force-with-lease (process.orchestrated-session); a fix is a new commit, never an amend. Never cd out of the worktree except to read the shared checkout read-only. Kill only PIDs you started.
|
||||
Worktree: ${WT} on branch ${BRANCH}; it exists, do ALL work inside it. Give it its own web/node_modules if missing (cp -Rc from ${SHARED}/web when the lockfiles match, else npm ci). If git commit is denied by the worktree-owner guard, the worktree belongs to ANOTHER session (orchestrated worktrees carry no marker): never overwrite the marker — STOP and report done=false with the guard's message. Every commit message ends with these trailer lines exactly:
|
||||
${TRAILER}`
|
||||
|
||||
const gateFor = (port, where) => `LOCAL GATE (process.local-gate-before-push) — inside ${where}, real output, a skipped test is not a pass:
|
||||
- .NET: dotnet build, then dotnet test on every test project covering what the branch touches (all of them for anything under ErsatzTV.Core); BOM-check touched .cs with od -A n -t x1 -N 3 and bash -c 'dotnet format whitespace . --folder --verify-no-changes --include <files>'.
|
||||
- SPA: cd web && npm run check:api && npm run lint && npm run typecheck && npm run build && npm test.
|
||||
- scripts/, .claude/, .husky/, .gitea/: PYTHONPATH=. python3 -m pytest scripts/tests -q, plus ruff on touched Python.
|
||||
- Docs: python3 scripts/check-doc-narrative.py --diff origin/main.
|
||||
- Live-E2E${args.needs_e2e ? ' IS REQUIRED (write path or UI)' : ' only for a write path or screen change'}: ETV_UI_PORT=${port} scripts/e2e-local.sh <fresh CONFIG_DIR> — port ${port} is yours; one run at a time in that worktree; curl, never a browser tab; kill the PID the launcher printed when done and nothing else; a busy port is reported, never taken over.
|
||||
- Run the .NET and web gates sequentially; other slots are building.`
|
||||
const GATE = gateFor(args.port, WT)
|
||||
|
||||
const REBASE = `Rebase onto origin/main FIRST: git fetch origin; git rebase origin/main; resolve conflicts faithfully, keeping both sides' intent; regenerate generated artifacts rather than hand-resolving them. A commit titled "WIP: orchestrator checkpoint" holds uncommitted work from the paused session and must be folded into the commit it belongs to, never left in history — if it sits directly on that commit: git reset --soft HEAD~1 && git commit --amend --no-edit; otherwise: git commit --fixup=<target> is already its shape, so GIT_SEQUENCE_EDITOR=true git rebase --autosquash <target>~1 folds it non-interactively.`
|
||||
|
||||
const REPORT_SCHEMA = {
|
||||
type: 'object', required: ['done', 'summary', 'verified', 'left', 'commits', 'head_sha'],
|
||||
properties: {
|
||||
done: { type: 'boolean' }, summary: { type: 'string' },
|
||||
verified: { type: 'string', description: 'exact gate commands run and their real output summary' },
|
||||
left: { type: 'string' }, commits: { type: 'string', description: 'git log --oneline origin/main..HEAD' }, pr_url: { type: 'string' }, head_sha: { type: 'string', description: 'git rev-parse HEAD of YOUR WORKTREE after your last commit (not a PR head) — the finisher derives fix commits from these' },
|
||||
patch_changed: { type: 'boolean', description: 'finisher only: true if a second rebase before the push changed the patch-id' },
|
||||
},
|
||||
}
|
||||
const FINDINGS_SCHEMA = {
|
||||
type: 'object', required: ['findings', 'verdict'],
|
||||
properties: {
|
||||
verdict: { type: 'string', enum: ['merge', 'send-back'] },
|
||||
findings: { type: 'array', items: { type: 'object', required: ['severity', 'file', 'summary', 'evidence'], properties: {
|
||||
severity: { type: 'string', enum: ['blocking', 'should-fix', 'nit'] }, file: { type: 'string' }, summary: { type: 'string' }, evidence: { type: 'string' } } } },
|
||||
},
|
||||
}
|
||||
|
||||
const RUNNER_SCHEMA = {
|
||||
type: 'object', required: ['findings', 'verdict', 'ran'],
|
||||
properties: {
|
||||
ran: { type: 'boolean', description: 'false if codex produced no VERDICT line — required, because the fallback branches on it' },
|
||||
verdict: FINDINGS_SCHEMA.properties.verdict, findings: FINDINGS_SCHEMA.properties.findings,
|
||||
},
|
||||
}
|
||||
const LAND_SCHEMA = {
|
||||
type: 'object', required: REPORT_SCHEMA.required.concat(['patch_changed']),
|
||||
properties: REPORT_SCHEMA.properties,
|
||||
}
|
||||
|
||||
phase('Work')
|
||||
let work
|
||||
if (args.mode === 'implement') {
|
||||
work = await agent(`${COMMON}
|
||||
|
||||
You are the implementer, continuing a paused session. Read git log and git show for the branch's commits first; a WIP checkpoint commit is the paused implementer's partial edit. ${REBASE} The brief's recon is a plan; verify what you rely on. Finish the done condition completely, with a regression test that reddens against the unfixed code. Run the LOCAL GATE and STOP without pushing; reviewers read the worktree first; report head_sha = git rev-parse HEAD of the worktree after your last commit. ${GATE}`,
|
||||
{ label: `impl:${REF}`, model: 'opus', effort: 'high', schema: REPORT_SCHEMA })
|
||||
} else {
|
||||
work = await agent(`${COMMON}
|
||||
|
||||
You are the fixer, continuing a paused session. The PR is #${args.pr}. ${REBASE} Then the brief's findings are the last review round's: fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong. Run the LOCAL GATE and STOP without pushing; reviewers read the worktree first; report head_sha = git rev-parse HEAD of the worktree after your last commit. ${GATE}`,
|
||||
{ label: `fix:${REF}`, model: 'opus', effort: 'high', schema: REPORT_SCHEMA })
|
||||
}
|
||||
if (!work) return { issues, error: 'work agent returned nothing' }
|
||||
if (!work.done) return { issues, error: 'work agent stopped', work }
|
||||
|
||||
const reviewCommon = (e2ePort) => `${COMMON}
|
||||
|
||||
${gateFor(e2ePort, 'your own isolated worktree (never ' + WT + ')')}
|
||||
|
||||
Diff: git -C ${WT} diff origin/main...HEAD (rebased, not yet pushed). ${args.pr ? `PR #${args.pr} exists: its pushed head, its body and any earlier closing record are INTENTIONALLY behind this worktree until the finisher pushes after this review loop and resyncs them — a stale PR head or body is not a finding, and neither is "not pushed".` : 'No PR exists yet; the finisher opens it after this loop.'} Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or test, do it in your own isolated worktree, never in ${WT}: git fetch ${WT} ${BRANCH} && git checkout --detach FETCH_HEAD puts the branch there; gates sequentially; E2E there on port ${e2ePort} (the GATE above is written for your worktree and that port).`
|
||||
|
||||
const LENSES = [
|
||||
{ key: 'correctness', model: 'opus', isolation: 'worktree', prompt: 'correctness against the done condition: run the gate and, for a write path or screen, the live-E2E route yourself, and read the output; try to break the change with the edge cases the issue and the docs name; check the pinning test reddens when the fix alone is reverted.' },
|
||||
{ key: 'conformance', model: 'sonnet', prompt: 'repo conformance: docs-update obligations met; no narrative in docs; inventory rows for new scripts/hooks; CPM respected; both-provider migration if the model changed; no BOM in touched .cs; no WIP commit left in history; branch rebased on current origin/main; commit trailers present; PR body will carry fixes #N for each issue.' },
|
||||
]
|
||||
let xfamilyFailedRound = null
|
||||
let xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)'
|
||||
async function codexRunner(round) {
|
||||
const r = await agent(`${reviewCommon(Number(args.port) + 3)}
|
||||
|
||||
You run the cross-family review required by process.independent-review-rubric; you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of branch ${BRANCH} against origin/main in ${WT} for ${REF} with done condition from the brief, findings as blocking / should-fix / nit with file and evidence, ending with VERDICT: merge or VERDICT: send-back. Run EXACTLY: codex exec -C ${WT} -s read-only "$(cat <prompt>)" < /dev/null > <out> 2>&1 in the background, wait for the PID to exit (Monitor; measured 2026-07-28 in the #672 session, ~35 minutes for a 7-file diff), read the file, return its findings faithfully with ran=true; no VERDICT line means the run failed — return ran=false, verdict merge, no findings, and the file's tail in one nit finding; never invent a verdict.`,
|
||||
{ label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: RUNNER_SCHEMA })
|
||||
return r
|
||||
}
|
||||
async function codexFallback(round, r) {
|
||||
xfamily = `codex could not run in round ${round} (${r ? 'no VERDICT line' : 'runner returned nothing'}); substituted a cold same-family review-only agent per process.independent-review-rubric — retry cross-family next window`
|
||||
log(`${REF}: ${xfamily}`)
|
||||
return agent(`${reviewCommon(Number(args.port) + 2)}
|
||||
|
||||
You are a COLD, review-only substitute for a cross-family reviewer that could not run. Lens: adversarial correctness AND security of the diff against the done condition in the brief. Run the gate in your own worktree; report only what you verified, with evidence. blocking = done condition or a repo rule violated; should-fix = real defect; nit = style. Verdict send-back if any blocking.`,
|
||||
{ label: `review:fallback:r${round}`, phase: 'Review', model: 'opus', effort: 'high', isolation: 'worktree', schema: FINDINGS_SCHEMA })
|
||||
}
|
||||
async function review(round) {
|
||||
// Per round, like blocking/sendBack: a substitute that failed in round 1 says nothing about the tree
|
||||
// that lands after round 2, and a stale xfamily string must never reach the PR body.
|
||||
xfamilyFailedRound = null
|
||||
xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)'
|
||||
// The Codex runner builds nothing, so it may run beside the lenses; the FALLBACK is a second
|
||||
// worktree-isolated .NET reviewer and starts only after both lenses have returned.
|
||||
const runnerPromise = rubric ? codexRunner(round).catch(() => null) : Promise.resolve(null)
|
||||
const lenses = (await parallel(LENSES.map(l => () => agent(`${reviewCommon(Number(args.port) + 1)}
|
||||
|
||||
Review round ${round} of the branch for ${REF}. Lens: ${l.prompt}
|
||||
Be adversarial; report only what you verified, with evidence. blocking = done condition or a repo rule violated, or a test that passes for the wrong reason; should-fix = real defect; nit = style. Verdict send-back if any blocking.`,
|
||||
{ label: `review:${l.key}:r${round}`, phase: 'Review', model: l.model, effort: 'high', isolation: l.isolation, schema: FINDINGS_SCHEMA })))).filter(Boolean)
|
||||
if (!rubric) return lenses
|
||||
const r = await runnerPromise
|
||||
if (r && r.ran === true) return lenses.concat([r])
|
||||
let fb = null
|
||||
try { fb = await codexFallback(round, r) } catch (e) { log(`${REF}: fallback reviewer threw: ${e && e.message}`) }
|
||||
if (!fb) { xfamily += ` — the substitute ALSO failed in round ${round}; no cross-family-equivalent review ran`; xfamilyFailedRound = round }
|
||||
return fb ? lenses.concat([fb]) : lenses
|
||||
}
|
||||
|
||||
let round = 1
|
||||
const actionable = rs => rs.flatMap(r => r.findings.filter(f => f.severity === 'blocking' || f.severity === 'should-fix'))
|
||||
const countBy = (rs, sev) => rs.flatMap(r => r.findings).filter(f => f.severity === sev).length
|
||||
let knownHead = work.head_sha
|
||||
let reviews = (await review(round)).filter(Boolean)
|
||||
if (!reviews.length) return { issues, error: `review round ${round} produced no reviews (every lens failed); not pushed`, history: [] }
|
||||
let blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking'))
|
||||
let sendBack = actionable(reviews)
|
||||
const history = [{ round, reviews, fix: null, fix_range: null }]
|
||||
while (sendBack.length && round < 3) {
|
||||
log(`${REF} round ${round}: ${blocking.length} blocking, ${sendBack.length - blocking.length} should-fix — sending back`)
|
||||
const fix = await agent(`${COMMON}
|
||||
|
||||
You are the fixer. Reviewers found these problems in the unpushed, rebased branch; fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong:
|
||||
${JSON.stringify(reviews.flatMap(r => r.findings.filter(f => f.severity !== 'nit')), null, 1)}
|
||||
Re-run the LOCAL GATE and STOP without pushing; report head_sha = git rev-parse HEAD of the worktree after your last commit. ${GATE}`,
|
||||
{ label: `fix:r${round}`, phase: 'Fix', model: 'opus', effort: 'high', schema: REPORT_SCHEMA })
|
||||
if (!fix || !fix.done) return { issues, error: `fixer for round ${round} ${fix ? 'stopped' : 'returned nothing'}; not pushed`, fix, history }
|
||||
history[history.length - 1].fix = fix
|
||||
history[history.length - 1].fix_range = fix.head_sha && fix.head_sha !== knownHead ? `${knownHead}..${fix.head_sha}` : null
|
||||
knownHead = fix.head_sha || knownHead
|
||||
round++
|
||||
reviews = (await review(round)).filter(Boolean)
|
||||
if (!reviews.length) return { issues, error: `review round ${round} produced no reviews (every lens failed); not pushed`, history }
|
||||
blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking'))
|
||||
sendBack = actionable(reviews)
|
||||
history.push({ round, reviews, fix: null, fix_range: null })
|
||||
}
|
||||
if (blocking.length) return { issues, error: 'blocking findings after two fix rounds; not pushed', blocking_remaining: blocking, history }
|
||||
if (sendBack.length) return { issues, error: 'should-fix findings still open after two fix rounds; not pushed — the orchestrator decides', should_fix_remaining: sendBack, history }
|
||||
if (xfamilyFailedRound) return { issues, error: `the cross-family runner and its substitute both failed in round ${xfamilyFailedRound}; not pushed`, cross_family: xfamily, history }
|
||||
const FIX_RANGES = history.map(h => h.fix_range).filter(Boolean)
|
||||
const REVIEW_HISTORY = history.map(h => `round ${h.round}: ${h.reviews.length} lens(es); ${countBy(h.reviews, 'blocking')} blocking, ${countBy(h.reviews, 'should-fix')} should-fix, ${countBy(h.reviews, 'nit')} nit` + (h.fix ? (h.fix_range ? `; answered by the fix commit(s) in git log --oneline ${h.fix_range}` : '; answered without a new commit (findings refuted with evidence in the fixer report)') : '; clean — loop ended')).join('\n')
|
||||
|
||||
phase('Land')
|
||||
const FINISH = `FINISH: record the patch-id (git diff $(git merge-base origin/main HEAD)..HEAD | git patch-id --stable); git fetch origin; if origin/main moved again, rebase onto it, re-run the LOCAL GATE, and recompute the patch-id — report patch_changed=true if it differs. ${GATE}
|
||||
Then git push --force-with-lease origin ${BRANCH}. ${args.pr ? `Update PR #${args.pr}'s body (PATCH ${API}/pulls/${args.pr}) so it describes the branch as it now is` : `Open a PR (POST ${API}/pulls; head=${BRANCH}, base=main)`}: the body must contain "fixes #N" for every issue in the bundle, the root cause for a bug fix, the measured numbers, the review history VERBATIM as recorded by the workflow, one line per round, between the markers <<REVIEW HISTORY and REVIEW HISTORY>>:
|
||||
<<REVIEW HISTORY
|
||||
${REVIEW_HISTORY}
|
||||
REVIEW HISTORY>>
|
||||
${FIX_RANGES.length ? `followed by what each fix commit changed, read from git show and not from memory, for exactly the commits git log --oneline lists in these ranges: ${FIX_RANGES.join('; ')}` : (history.some(h => h.fix) ? 'and a sentence saying every finding was answered without a new commit, as the history block records' : 'and a sentence saying no fix commit exists because round one was clean')}, then the cross-family review status verbatim — "${xfamily}" — every deliberately-left item with an issue number, and end with:
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
|
||||
${SESSION_URL}
|
||||
Read ${API}/commits/<head-sha>/status once to arm the CI monitor. Post or update the "## Closing record" comment on each issue (the closing-an-issue skill's template) linking the PR, without closing the issue and without ticking any "## Done-when" box; end it with a "Done-when evidence" list naming, for every box, the command or artifact that evidences it — the orchestrator ticks from that. Report the PR URL, the head sha and patch_changed.`
|
||||
const land = await agent(`${COMMON}
|
||||
|
||||
You are the finisher. The rebased branch has passed its review loop (${round} round(s)); nothing is pushed yet. ${FINISH}`,
|
||||
{ label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: LAND_SCHEMA })
|
||||
if (!land || !land.done) return { issues, error: 'finisher stopped', land, history }
|
||||
if (!(land.pr_url || args.pr) || !land.head_sha) return { issues, error: 'finisher reported done without a PR or head sha — the branch may already be pushed; read its report before re-running', land, history }
|
||||
log(`${REF} PR: ${land.pr_url || args.pr} @ ${land.head_sha || '?'}${land.patch_changed ? ' (patch changed by the pre-push rebase)' : ''}`)
|
||||
let post_rebase_reviews = null
|
||||
if (land.patch_changed) {
|
||||
log(`${REF}: patch changed on rebase — one more review round on the pushed head before any verdict`)
|
||||
round++
|
||||
post_rebase_reviews = (await review(round)).filter(Boolean)
|
||||
if (!post_rebase_reviews.length) return { issues, error: 'the post-rebase review round produced no reviews (every lens failed); pushed, no verdict may be posted', pr_url: land.pr_url || args.pr, head_sha: land.head_sha, cross_family: xfamily, history }
|
||||
const late = actionable(post_rebase_reviews)
|
||||
if (late.length) return { issues, error: 'blocking or should-fix findings on the pushed head after the pre-push rebase; no verdict may be posted', pr_url: land.pr_url || args.pr, head_sha: land.head_sha, findings_remaining: late, cross_family: xfamily, history, post_rebase_reviews }
|
||||
}
|
||||
return { issues, pr_url: land.pr_url || args.pr, head_sha: land.head_sha, patch_changed: !!land.patch_changed, cross_family: xfamily, work, land, history, post_rebase_reviews }
|
||||
@@ -60,8 +60,13 @@ on:
|
||||
# this file no longer republishes on its own, so pair it with a `docker/ci/**` edit.
|
||||
#
|
||||
# `paths:` here and `ci-image-pin`'s `expected` pathspec in pr-checks.yml MUST name the same
|
||||
# sources, and nothing mechanically enforces that since the shared self-reference went —
|
||||
# ersatztv#855.
|
||||
# sources. Since the shared self-reference went, `scripts/tests/test_ci_image_paths_pin_agreement.py`
|
||||
# is what holds them together: it derives BOTH lists from these two workflows and compares them for
|
||||
# set equality (ersatztv#855). The two are written in different glob dialects, so it models exactly
|
||||
# one pair of spellings — `<dir>/**` here against the pathspec `<dir>` — and REFUSES anything else
|
||||
# rather than canonicalising a pattern space whose spellings the two consumers treat differently.
|
||||
# Change this list and that guard goes red until the pathspec follows; write it any other way and
|
||||
# it goes red asking for the new shape to be modelled.
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
@@ -120,16 +120,32 @@ env:
|
||||
# is NOT granted, and that holds at any owner default — it is not conditional on Restricted being on.
|
||||
# Only `review-verdict.yml` needs write; it declares that at the job and says why there. Full
|
||||
# rationale and the per-workflow credential audit: docs/ci-cd.md -> "Workflow token scope".
|
||||
# Every credentialed thing this file does uses the scoped REGISTRY_* PAT, never the injected token:
|
||||
# its registry pushes, its five `container:` image pulls, its three commit-status GET steps
|
||||
# (`ETV_STATUS_AUTH` in jobs `test`, `migrations` and `functional-e2e`, each a read-only GET via
|
||||
# scripts/ci-detect-already-validated.sh) and its registry tag READ (`ETV_REGISTRY_AUTH` in job
|
||||
# `toolchain-preflight`, via scripts/ci-toolchain-image-resolves.sh). The injected token therefore
|
||||
# serves only its eight `actions/checkout` steps. Note this file needs no `packages:` unit for that
|
||||
# same reason: the `container:` blocks carry explicit `credentials:`.
|
||||
# NO JOB ON THE `pull_request` ROUTE NAMES A STORED SECRET (ersatztv#885,
|
||||
# `ci.pr-route-carries-no-stored-credential`). Gitea resolves a `pull_request` run from the PR HEAD,
|
||||
# so this file is attacker-authored on that route and every `secrets.*` it names is materialised
|
||||
# into the run. The scoped REGISTRY_* PAT is therefore held by `build` alone, which is gated
|
||||
# `if: github.event_name != 'pull_request'`. The jobs that used to hold it now work without it:
|
||||
# the five `container:` pulls and `toolchain-preflight`'s registry tag READ go through the registry's
|
||||
# anonymous bearer-token flow, and the three commit-status GETs
|
||||
# (scripts/ci-detect-already-validated.sh in `test`, `migrations` and `functional-e2e`) read the
|
||||
# combined-status API unauthenticated. Those are TWO dependencies, on two different objects, with
|
||||
# opposite failure directions — do not collapse them into one "keep it public or CI breaks loudly".
|
||||
# The `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version of it
|
||||
# reports `"repository": null`), so THIS repo's visibility is not what gates the anonymous pull
|
||||
# token. (1) The five pulls and the preflight need that PACKAGE to stay anonymously pullable, and
|
||||
# losing it IS loud: every `container:` job dies at image pull, before it runs a step, both
|
||||
# required contexts among them, and `toolchain-preflight` names the cause in its own 401/403
|
||||
# message. (2) The three commit-status GETs need `timothy/ersatztv` itself to stay publicly
|
||||
# readable, and losing that is SILENT: `curl -sf` fails, ci-detect-already-validated.sh falls
|
||||
# through to `skip=false`, and the jobs stay GREEN — only the ersatztv#420 cross-run skip quietly
|
||||
# stops firing, which costs a redundant re-validation and never a skip that was not earned. The
|
||||
# invariant is held by
|
||||
# scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret.
|
||||
# The injected token serves only this file's eight `actions/checkout` steps. Note it needs no
|
||||
# `packages:` unit: the container pulls are anonymous, not token-authenticated.
|
||||
# (Sites above are named by JOB, not by line number: this file is ~1150 lines, so any edit above a
|
||||
# citation silently invalidates it — which is how the first version of this comment went stale two
|
||||
# lines after it was written.)
|
||||
# citation silently invalidates it — a line-number citation here has gone stale within two lines
|
||||
# of being written.)
|
||||
permissions:
|
||||
code: read
|
||||
|
||||
@@ -150,8 +166,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Resolve the pinned toolchain tag in the registry
|
||||
env:
|
||||
ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark resolve
|
||||
scripts/ci-toolchain-image-resolves.sh
|
||||
@@ -165,9 +179,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
env:
|
||||
CI_EXECUTION_CLASS: toolchain
|
||||
CI_JOB_ROLE: guard
|
||||
@@ -195,8 +206,6 @@ jobs:
|
||||
scripts/ci-detect-docs-only.sh
|
||||
- name: Detect already-validated tree (#420)
|
||||
id: revalidate
|
||||
env:
|
||||
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark revalidate
|
||||
scripts/ci-detect-already-validated.sh
|
||||
@@ -389,9 +398,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
# Independent gate (not a 'needs' of build yet) so the new MySql-service dependency
|
||||
# can't block image builds until it's proven reliable on the runner. Promote to a
|
||||
# required check / build dependency once green. (ersatztv#13)
|
||||
@@ -468,8 +474,6 @@ jobs:
|
||||
scripts/ci-detect-docs-only.sh
|
||||
- name: Detect already-validated tree (#420)
|
||||
id: revalidate
|
||||
env:
|
||||
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark revalidate
|
||||
scripts/ci-detect-already-validated.sh
|
||||
@@ -611,9 +615,6 @@ jobs:
|
||||
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
env:
|
||||
CI_EXECUTION_CLASS: toolchain
|
||||
CI_JOB_ROLE: guard
|
||||
@@ -632,8 +633,6 @@ jobs:
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
- name: Detect already-validated tree (#420)
|
||||
id: revalidate
|
||||
env:
|
||||
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: scripts/ci-detect-already-validated.sh
|
||||
|
||||
- name: Cache NuGet packages
|
||||
@@ -727,9 +726,9 @@ jobs:
|
||||
# runs at all — the image is not built, let alone pushed. Fail-closed by dependency, not by
|
||||
# assertion.
|
||||
#
|
||||
# WHY IT RUNS THE REAL PYTEST rather than a bespoke scanner. The first cut of #767 hand-parsed the
|
||||
# workflow YAML in stdlib Python, to avoid provisioning PyYAML on `build`'s bare runner. Two
|
||||
# independent reviews found ~10 false NEGATIVES in that parser within one round (flow mappings
|
||||
# WHY IT RUNS THE REAL PYTEST rather than a bespoke scanner. A stdlib hand-parser of the workflow
|
||||
# YAML was TRIED AND REJECTED in #767 (its appeal: no PyYAML to provision on `build`'s bare
|
||||
# runner). That parser had ~10 false NEGATIVES, all found at once (flow mappings
|
||||
# `{run: …}`, a quoted `"run":` key, aliases, multiline quoted scalars) — i.e. it was strictly
|
||||
# WEAKER than the check it was meant to backstop, in the one direction that matters for a security
|
||||
# gate. Running the existing PyYAML-based test needs no second implementation of "what is a `run:`
|
||||
@@ -785,16 +784,16 @@ jobs:
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark ban
|
||||
PYTHONPATH=. python3 -m pytest scripts/tests/test_ci_dropped_step_guard.py scripts/tests/test_ci_release_path_scan_job.py -q
|
||||
# THE POSITIVE CONTROL, and it is deliberately NOT a test (ersatztv#767). The step above proves
|
||||
# the ban HOLDS; it cannot prove the ban would NOTICE. Review disarmed the entire gate with one
|
||||
# repo-root `pytest.ini` (`addopts = -k "not delimiter_banned"`) or `conftest.py`
|
||||
# (`pytest_collection_modifyitems`), which deselects the ban test and every test guarding it,
|
||||
# the ban HOLDS; it cannot prove the ban would NOTICE. DEMONSTRATED: ONE repo-root `pytest.ini`
|
||||
# (`addopts = -k "not delimiter_banned"`) or `conftest.py` (`pytest_collection_modifyitems`)
|
||||
# disarms the entire gate, deselecting the ban test and every test guarding it,
|
||||
# leaving all jobs green with a delimiter sitting in `Smoke`. Nothing inside pytest can be
|
||||
# trusted to catch that, because pytest's own configuration outranks it.
|
||||
#
|
||||
# So this poisons the checked-out workflow, re-runs the SAME command, and fails the job if it
|
||||
# PASSES. It runs in the real checkout — an isolated copy does not inherit the repo-root config
|
||||
# a disarm would live in, which made the first version of this script report healthy while the
|
||||
# job's real invocation was deselected. The workflow file is restored by an EXIT trap.
|
||||
# a disarm would live in, so a check run in a copy reports healthy while the
|
||||
# job's real invocation is deselected. The workflow file is restored by an EXIT trap.
|
||||
- name: Prove the ban would DETECT a delimiter (ersatztv#767)
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark selfcheck
|
||||
@@ -1044,9 +1043,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
CI_EXECUTION_CLASS: toolchain
|
||||
@@ -1149,9 +1145,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
CI_EXECUTION_CLASS: toolchain
|
||||
|
||||
@@ -106,9 +106,14 @@ jobs:
|
||||
# runner placement than for build recipe. Make a recipe change alongside a `docker/ci/**`
|
||||
# edit — a comment bump suffices, and it is the ONLY remedy: pinning the workflow-only
|
||||
# commit is rejected here, because `expected` is the last `docker/ci` commit.
|
||||
# Nothing MECHANICALLY couples this pathspec to `ci-image.yml`'s `on.push.paths`; before
|
||||
# This pathspec and `ci-image.yml`'s `on.push.paths` MUST name the same sources; before
|
||||
# #744 the shared self-reference kept them in step. Divergence is silent and green in the
|
||||
# dangerous direction — tracked in ersatztv#855.
|
||||
# dangerous direction, so it is enforced rather than asserted:
|
||||
# `scripts/tests/test_ci_image_paths_pin_agreement.py` derives BOTH lists from the two
|
||||
# workflows and compares them for set equality (ersatztv#855). It takes this pathspec from
|
||||
# the ASSIGNMENT below rather than from any `git log` in the job, and models only a plain
|
||||
# `<dir>` against `<dir>/**` there — any other spelling is refused rather than compared.
|
||||
# Change this pathspec and that guard goes red until `on.push.paths` follows.
|
||||
# See docs/ci-cd.md -> "Publishing from a branch is a dispatch, not a push".
|
||||
#
|
||||
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
|
||||
@@ -535,9 +540,9 @@ jobs:
|
||||
# is installed, but the one-shot WRITE path `migrate_decisions_split.py` uses PyYAML by
|
||||
# design — and `test_migration_equivalence.py` imports that module, so the suite needs it.
|
||||
# `pytest` and `yaml` are the complete third-party set, established by an AST import scan over
|
||||
# all of scripts/ rather than by reading the files that seemed relevant: the first cut of this
|
||||
# job claimed "pure stdlib", passed locally on a machine that happened to have PyYAML, and
|
||||
# went red in CI on a collection error.
|
||||
# all of scripts/ rather than by reading the files that seemed relevant — reading only those
|
||||
# yields "pure stdlib", a claim that passes locally on a machine that happens to have PyYAML
|
||||
# and goes red in CI on a collection error.
|
||||
- name: Install test dependencies
|
||||
run: python3 -m pip install --disable-pip-version-check --quiet pytest pyyaml
|
||||
# jq gets its OWN step because its VERSION, not merely its presence, is load-bearing
|
||||
|
||||
@@ -73,6 +73,40 @@ name: Review verdict
|
||||
# ignored: the BASE definition ran and posted `h10=pending`, on both `opened` and `synchronize`,
|
||||
# with `secrets` still available.
|
||||
#
|
||||
# THE DEFINITION-RESOLUTION HALF is re-established FROM THE v1.27.1 SOURCE, 2026-09-02
|
||||
# (ersatztv#869) — the mechanism behind the measurement above. In
|
||||
# `services/actions/notifier_helper.go` one function resolves the two triggers from two SEPARATELY
|
||||
# RESOLVED refs: for a PR event `input.Ref` resolves to the PR's own HEAD ref (`WithPullRequest`
|
||||
# defaults it to `pr.GetGitHeadRefName()` when the caller has not already set one), and workflows
|
||||
# detected at that commit are kept only if their trigger is NOT `pull_request_target`; the base is
|
||||
# then resolved separately as `git.BranchPrefix + input.PullRequest.BaseBranch`, and workflows
|
||||
# detected there are kept only if their trigger IS `pull_request_target`. A head rewrite cannot
|
||||
# reach this file's definition at all.
|
||||
#
|
||||
# WHICH HALF, precisely — the source covers definition resolution and NOTHING ELSE. It does not
|
||||
# speak to which `types` produce a run, nor to secrets availability.
|
||||
#
|
||||
# What IS established on 1.27.1, and stated no wider than it was measured: `pull_request_target`
|
||||
# fires for THIS workflow continuously — 210 such runs across 189 distinct head shas, counting only
|
||||
# runs started strictly after the 2026-08-05 upgrade (measured 2026-09-02). The WINDOW is part of
|
||||
# the claim, and the all-time REPO-WIDE figure is the WRONG window: 258 runs /
|
||||
# 233 shas = 44 before the upgrade date + 4 ON it + 210 after. Citing 258 here would import
|
||||
# 1.25.4 evidence into a 1.27.1 sentence. This workflow's own all-time count is 250; the difference
|
||||
# is 8 runs from three since-deleted probe workflows, all pre-upgrade — which is why every one of
|
||||
# the 210 post-upgrade runs is this file.
|
||||
# The 4 same-day runs are excluded rather than assigned, because the upgrade landed mid-session and
|
||||
# their 17:41-22:36 UTC start times cannot be placed either side of it without the exact upgrade
|
||||
# time. Read those as UTC deliberately: this endpoint returns `started_at` Z-suffixed, while other
|
||||
# Gitea timestamps render in the host zone, Europe/Brussels — `+02:00` in August, `+01:00` in
|
||||
# winter — so a UI comparison shows different clock times.
|
||||
# What that does NOT establish, deliberately: WHICH `types:` fire (a run count identifies no event
|
||||
# type), and whether the STORED secret store is reachable under this trigger — this job references
|
||||
# only the injected `secrets.GITEA_TOKEN`. Both of those stay 1.25.4-dated.
|
||||
#
|
||||
# That is also why `branches: [main]` is load-bearing rather than tidy: the base is resolved by
|
||||
# BRANCH NAME, not by a pinned sha, so the base definition is whatever that branch points at when
|
||||
# the event fires.
|
||||
#
|
||||
# `pull_request_target` is normally the DANGEROUS trigger, and it is worth being explicit about why
|
||||
# that reputation does not transfer here. Its footgun is running untrusted HEAD code with a
|
||||
# privileged token. This job never checks out the head and never executes anything the PR supplies:
|
||||
@@ -251,7 +285,7 @@ jobs:
|
||||
# exactly one event — the next run saw a machine-written `pending`, re-derived it, and
|
||||
# posted `success` again, with its own freshly-taken high-water mark now ABOVE the human
|
||||
# row, so the post-write check stayed silent and the rejection went green a second time.
|
||||
# Found by cold review. Refusing here can only ever withhold an exemption, never grant one.
|
||||
# Refusing here can only ever withhold an exemption, never grant one.
|
||||
REPAIR_DESC="Human verdict raced this exemption write — re-post the verdict"
|
||||
# THE SECOND SENTINEL, and the two are NOT interchangeable (ersatztv#849). `REPAIR_DESC`
|
||||
# asserts a fact — a human verdict existed and this job's write buried it. Most of the
|
||||
@@ -351,8 +385,8 @@ jobs:
|
||||
# Package Management versions live in `Directory.Packages.props`, so a `.csproj` edit
|
||||
# attributed to Renovate is anomalous by construction. Such a PR is not blocked, it simply
|
||||
# needs a real verdict, which is the correct handling for a PR carrying source changes.
|
||||
# NOTE the npm manifests are deliberately ABSENT. An earlier draft included
|
||||
# `web/package.json` / `web/package-lock.json` "so a first SPA bump cannot deadlock". That was
|
||||
# NOTE the npm manifests are deliberately ABSENT. Including
|
||||
# `web/package.json` / `web/package-lock.json` "so a first SPA bump cannot deadlock" would be
|
||||
# a self-inflicted code-execution vector for zero benefit: `renovate.json` sets
|
||||
# `enabledManagers: ["nuget", "github-actions", "dockerfile"]`, so Renovate does not manage npm
|
||||
# in this repo at all, while `package.json` carries `scripts` that CI EXECUTES (`npm ci`,
|
||||
@@ -382,7 +416,7 @@ jobs:
|
||||
# not prose about the project — they are the documents that DEFINE the completion protocol,
|
||||
# the merge-consent convention and the H10 rule itself. `.claude/` being protected while the
|
||||
# file that specifies what `.claude/` enforces was docs-only-exempt is the same
|
||||
# self-exemption the header rules out, one directory over. Found by cold review
|
||||
# self-exemption the header rules out, one directory over. Measured
|
||||
# (ersatztv#751): driving the real classify body with a lone `CLAUDE.md` change produced
|
||||
# `review-verdict/h10=success`, "Exempt: docs-only change (no code, no protected path)".
|
||||
#
|
||||
@@ -447,9 +481,9 @@ jobs:
|
||||
# every time. Measured 2026-08-29, this repo puts 8 contexts on a `main` head. That case
|
||||
# already stalled before this change — with an ABSENT required check, which reads as "not
|
||||
# reviewed yet" — so what changes is that the stall now says why.
|
||||
# THE RETURN VALUE IS THE WHOLE POINT, and the first version did not have one: the `else`
|
||||
# branch ended with a successful `echo`, so the function returned 0 after BOTH POSTs failed
|
||||
# and its fence caller took the `exit 0` beside it as though the head had been marked. An
|
||||
# THE RETURN VALUE IS THE WHOLE POINT. Without one the `else`
|
||||
# branch ends with a successful `echo`, so the function returns 0 after BOTH POSTs failed
|
||||
# and its fence caller takes the `exit 0` beside it as though the head had been marked. An
|
||||
# explicit `return` per arm, and every caller acts on it.
|
||||
replace_unknown_state() { # $1 = the ::error:: naming what could not be established
|
||||
echo "::error::$1"
|
||||
@@ -474,10 +508,10 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
|
||||
# DEFINED HERE, BEFORE ANY USE. An earlier round defined these AFTER the classification
|
||||
# chain that calls them, so `count_matching` was `command not found` on every run, the
|
||||
# PROTECTED branch silently never fired, and three "protected path" tests still passed —
|
||||
# they reached `pending` by another route, so the guard being dead was invisible.
|
||||
# DEFINED HERE, BEFORE ANY USE. Defining them AFTER the classification
|
||||
# chain that calls them makes `count_matching` `command not found` on every run, the
|
||||
# PROTECTED branch silently never fires, and three "protected path" tests still pass —
|
||||
# they reach `pending` by another route, so the guard being dead is invisible.
|
||||
#
|
||||
# HOW THE PATH PREDICATES ARE EVALUATED, and why neither obvious spelling is used.
|
||||
#
|
||||
@@ -525,7 +559,7 @@ jobs:
|
||||
|
||||
|
||||
# --- Is there already a verdict for THIS sha? ----------------------------------------
|
||||
# NOTE the heading no longer says "never overwrite". It cannot promise that: the read below
|
||||
# NOTE this heading does NOT say "never overwrite". It cannot promise that: the read below
|
||||
# and the POST at the end of this job are not atomic, so a human verdict posted in between is
|
||||
# still overwritten. The re-read immediately before the POST narrows that window; it does not
|
||||
# close it. Tracked as ersatztv#706 rather than claimed as solved.
|
||||
@@ -548,8 +582,8 @@ jobs:
|
||||
read_existing_verdict() {
|
||||
local json row rv try
|
||||
# `.statuses` IS `null`, NOT `[]`, ON A HEAD WITH NO STATUSES YET — the same nil-slice
|
||||
# serialization as the timeline terminator, found by cold review of the fix for that one
|
||||
# (ersatztv#751). Measured on this instance: PR #739's head 5fa672e2 returns
|
||||
# serialization as the timeline terminator (ersatztv#751).
|
||||
# Measured on this instance: PR #739's head 5fa672e2 returns
|
||||
# `{"state":"pending","total_count":0,"statuses":null}`. An `array`-only gate read that as
|
||||
# unreadable and took the `exit 1` below, so the job posted NOTHING — fail-closed, but the
|
||||
# user-visible outcome is exactly the one this issue is about: an exempt PR left with no
|
||||
@@ -599,7 +633,7 @@ jobs:
|
||||
st_kind=$(printf '%s' "$json" | jq -r '.statuses | type' 2>/dev/null) || st_kind=""
|
||||
# NUMBER, not `jq -r` text: `jq -r` renders the JSON number 0 and the JSON string "0"
|
||||
# identically, so a schema-corrupted `"total_count": "0"` would satisfy a string compare
|
||||
# (cold review reproduced this). Requiring the type as well pins the accept path to a real
|
||||
# (measured, not assumed). Requiring the type as well pins the accept path to a real
|
||||
# numeric zero.
|
||||
st_total=$(printf '%s' "$json" | jq -r 'if (.total_count | type) == "number" then (.total_count | tostring) else "x" end' 2>/dev/null) || st_total="x"
|
||||
st_ok=no
|
||||
@@ -637,7 +671,7 @@ jobs:
|
||||
replace_unknown_and_die "Could not read existing commit statuses for ${SHA:0:7} (.statuses was '${st_kind:-unparseable}', total_count '${st_total}') after a retry, so any ${CONTEXT} already on this head — including one posted by an account this gate does not accept verdicts from — can neither be read nor re-derived."
|
||||
fi
|
||||
# `// []` so the null case cannot hard-error here under `set -e` once it is accepted above.
|
||||
# TYPE-SAFE, AND A SCHEMA FAILURE REPLACES RATHER THAN DYING (ersatztv#849 round 3).
|
||||
# TYPE-SAFE, AND A SCHEMA FAILURE REPLACES RATHER THAN DYING (ersatztv#849).
|
||||
# `.statuses` being an array was checked; its ELEMENTS were not. A single scalar in that
|
||||
# array makes `select(.context == ...)` hard-error, jq exits 5, and under `set -e` this
|
||||
# unguarded assignment took the step down — BEFORE any of the replacement paths below,
|
||||
@@ -668,7 +702,7 @@ jobs:
|
||||
# direction for a required check, which must withhold a green rather than grant one.
|
||||
unreadable=$(printf '%s' "$json" | jq -r '[(.statuses // [])[] | select(type != "object" or ((.context | type) != "string"))] | length' 2>/dev/null) || unreadable=""
|
||||
case "$unreadable" in ''|*[!0-9]*) unreadable=1 ;; esac
|
||||
# ANY UNREADABLE ELEMENT, whether or not a target row was also found. Round 8 scoped this
|
||||
# ANY UNREADABLE ELEMENT, whether or not a target row was also found. A narrowing scoped it
|
||||
# to "no readable row was found", on the reasoning that a malformed row BESIDE one we did
|
||||
# read is noise. That reasoning does not hold: an element whose `.context` cannot be read
|
||||
# cannot be shown to be a different context, so it may be a mangled rendering of this
|
||||
@@ -724,8 +758,8 @@ jobs:
|
||||
# 736649b3 returned 8 rows keyed id/context/status/creator/description/created_at/
|
||||
# updated_at/url, ids 14..30 ascending. `// ""` so a server that ever stopped sending it
|
||||
# degrades to the pre-existing text comparison rather than to a false "changed".
|
||||
# A WRONG TYPE IS NOT AN ABSENT VALUE (ersatztv#849 round 4). Round 3 type-tested these
|
||||
# four fields and resolved a failure to `""` — which for `.creator` means "no creator",
|
||||
# A WRONG TYPE IS NOT AN ABSENT VALUE (ersatztv#849). Type-testing these
|
||||
# four fields and resolving a failure to `""` means, for `.creator`, "no creator",
|
||||
# i.e. unattributable, i.e. RE-DERIVE. That turned a corrupt row into an exemption
|
||||
# `success` where `origin/main` had died before writing anything: measured, a head
|
||||
# carrying `h10=failure` with `"creator": 7` posts `Exempt: docs-only change` here and
|
||||
@@ -750,9 +784,7 @@ jobs:
|
||||
# `.creator.login` HARD-ERRORS on any non-object creator — the same defect #763 fixed in
|
||||
# the post-write filter, still live on this read. `(.creator | type)` short-circuits it,
|
||||
# so the step is not killed; the wrong TYPE then takes the fault route above rather than
|
||||
# reading as "no creator", which is what made it re-derivable. (An earlier version of
|
||||
# this comment described that re-derivation as the intended behaviour — it was the
|
||||
# regression the block above records.)
|
||||
# reading as "no creator", which is what made it re-derivable.
|
||||
ex_creator=$(printf '%s' "$row" | jq -r --arg f "$SCHEMA_FAULT" 'if (.creator | type) == "object" then (.creator.login // "") elif (.creator | type) == "null" then "" else $f end')
|
||||
ex_desc=$(printf '%s' "$row" | jq -r --arg f "$SCHEMA_FAULT" 'if (.description | type) == "string" then .description elif (.description | type) == "null" then "" else $f end')
|
||||
for _f in "$ex_id" "$ex_state" "$ex_creator" "$ex_desc"; do
|
||||
@@ -763,8 +795,8 @@ jobs:
|
||||
# A `case` prefix test rather than grep: the description is a single short string, and this
|
||||
# removes one more pipeline from a security predicate entirely. The PATTERN is a literal, so
|
||||
# there is no glob-injection concern from $ex_desc.
|
||||
# A human verdict also has to have been formed against THIS base (ersatztv#698, found in
|
||||
# round-4 review). `post-review-verdict.sh` records the base it reviewed in the status
|
||||
# A human verdict also has to have been formed against THIS base (ersatztv#698).
|
||||
# `post-review-verdict.sh` records the base it reviewed in the status
|
||||
# description — `Review-verdict: MERGEABLE @ abc1234 (base: main)` — precisely because
|
||||
# retargeting changes the effective diff without moving the head sha (ersatztv#632).
|
||||
# Without this check the sha-binding is escapable through the HUMAN path rather than the
|
||||
@@ -968,8 +1000,7 @@ jobs:
|
||||
# triggered (push -> `pull_request_target` run) pairs on PRs #802, #834 and #761 the
|
||||
# `pull_push` event predates its own run's `started_at` by 26-102s, itself a lower bound
|
||||
# since the job runs a checkout and several steps before counting (69s end to end on run
|
||||
# 2385). An earlier version of this comment rested the claim on that margin alone and
|
||||
# called it a deployment property; see `ci.verdict-write-retarget-fence`, which also names
|
||||
# 2385). See `ci.verdict-write-retarget-fence`, which also names
|
||||
# the one window this does NOT cover — a PR's FIRST push is recorded about a second after
|
||||
# creation and fires no `synchronize`, so an `opened` run landing inside it would abstain
|
||||
# with no successor queued.
|
||||
@@ -1101,12 +1132,11 @@ jobs:
|
||||
# instance; the worst case with the per-page retry below is 40 requests and 20
|
||||
# one-second sleeps — every page blipping once and recovering — which is 20s of
|
||||
# sleep on top. MEASURED by executing this function against scripted responses,
|
||||
# not counted by eye: the first draft of this sentence said 19, reasoning that
|
||||
# the last page could not sleep. It can.
|
||||
# not counted by eye: 19 would be the count if the last page could not sleep. It can.
|
||||
#
|
||||
# WALL-CLOCK, which is what the timeout is actually for. An earlier version of this
|
||||
# comment reasoned that only the last page can pay two timeouts, since a page whose
|
||||
# BOTH attempts fail ends the walk, and put the bound at ~335s. That bounds TIMEOUTS,
|
||||
# WALL-CLOCK, which is what the timeout is actually for. A PLAUSIBLE reading is
|
||||
# that only the last page can pay two timeouts, since a page whose BOTH attempts
|
||||
# fail ends the walk, which puts the bound at ~335s. That bounds TIMEOUTS,
|
||||
# not elapsed time: attempt 1 can burn its full 15s and attempt 2 still SUCCEED
|
||||
# slowly, so the page costs ~31s and the walk carries on. The pessimum is therefore
|
||||
# 20 x (15 + 1 + 15) = 620s per walk — ~21min on the exempt path's three walks — and
|
||||
@@ -1127,7 +1157,7 @@ jobs:
|
||||
# count with `rt_ok=yes`. Both re-reads share the blind spot, so the counts agree and
|
||||
# the exemption is granted — the SAME construction as before, at the cap instead of at
|
||||
# page 2, at roughly 10x the price. State that price ONCE and in one unit, because
|
||||
# two earlier drafts of this comment stated it two ways 45 lines apart. The FILTERED
|
||||
# two statements of it in one file drift into disagreement. The FILTERED
|
||||
# BLOCK is 50 rows in both cases — that is the part which must be inline review
|
||||
# comments. What grew is the timeline the attacker has to build around it: ~100 rows
|
||||
# before (a non-empty page 1, then a 50-row block at a 50-aligned offset) against more
|
||||
@@ -1175,8 +1205,8 @@ jobs:
|
||||
# figure that rots: a real PR's timeline always carries at least one event on page 1
|
||||
# (the PR is created by a push, which is itself an event). Spot-checked across
|
||||
# #752/#753/#749/#739/#717, all non-empty; the counts themselves are deliberately
|
||||
# not recorded here because timelines grow and an earlier version of this comment
|
||||
# cited five numbers of which three were stale within days. Trusting a zero count
|
||||
# not recorded here because timelines grow and three of five such
|
||||
# counts went stale within days. Trusting a zero count
|
||||
# from an anomalous first page would mean trusting that no retarget happened on the
|
||||
# strength of a response we cannot explain, so it falls through to `rt_ok=no`, which
|
||||
# withholds the exemption and asks for a human verdict — the safe direction.
|
||||
@@ -1204,8 +1234,8 @@ jobs:
|
||||
# truthiness of its LAST OUTPUT, which is the exit-status subtlety that already bit
|
||||
# this workflow at jq 1.6 (ersatztv#647), and the runner still ships 1.6. A count read
|
||||
# as a VALUE and then range-checked in shell cannot be read two ways, and it matches
|
||||
# every other tally here. (The first draft of this guard used `jq -e`, safely by
|
||||
# accident — the array is known non-empty by the check above, so the empty-input
|
||||
# every other tally here. (Using `jq -e` here would be safe by accident — the
|
||||
# array is known non-empty by the check above, so the empty-input
|
||||
# divergence could not fire — but "safe because of a property three lines up" is
|
||||
# exactly the reasoning the rule exists to retire.)
|
||||
bad=$(printf '%s' "$raw" | jq -r '[.[] | select(type != "object" or (.type | type) != "string")] | length' 2>/dev/null) || bad=""
|
||||
@@ -1238,8 +1268,8 @@ jobs:
|
||||
# Both callers below want the WHOLE history, and a single `?limit=100` read cannot give it:
|
||||
# `limit` clamps to the server-wide `MAX_RESPONSE_ITEMS`, measured at 50.
|
||||
#
|
||||
# BE PRECISE ABOUT WHAT THAT COST, because the issue and an earlier version of this comment
|
||||
# both overstated it. Under the server default (`created_unix DESC`) page 1 holds the NEWEST
|
||||
# BE PRECISE ABOUT WHAT THAT COST, because the issue overstates it.
|
||||
# Under the server default (`created_unix DESC`) page 1 holds the NEWEST
|
||||
# rows, and ids are monotonic with `created_at` (measured). So page 1 already carried the
|
||||
# true maximum id, AND every row newer than the mark — which is the only thing the
|
||||
# post-write check selects on. A single-page read therefore missed a raced verdict only if
|
||||
@@ -1289,7 +1319,7 @@ jobs:
|
||||
# EVERY exit publishes what WAS read, not just the complete ones. `ph_ok` alone says
|
||||
# whether the list is whole; a caller that can still use a partial list must be able to
|
||||
# reach it, and the high-water mark below is exactly such a caller — see the fail-open it
|
||||
# otherwise creates (ersatztv#763, round 2).
|
||||
# otherwise creates (ersatztv#763).
|
||||
while [ "$page" -le 20 ]; do
|
||||
raw=""
|
||||
kind=""
|
||||
@@ -1364,12 +1394,47 @@ jobs:
|
||||
# The guard still exists for its original reason: re-posting `pending` over a real human
|
||||
# verdict would un-approve a reviewed head and stall the PR. So it discriminates by PROVENANCE.
|
||||
#
|
||||
# MEASURED on this instance (Gitea 1.25.4), on the COMBINED endpoint this job reads: a status
|
||||
# MEASURED on this instance, on the COMBINED endpoint this job reads: a status
|
||||
# POSTed with a USER credential — how `scripts/post-review-verdict.sh` writes a verdict —
|
||||
# carries `.creator.login`, while one POSTed by an Actions job with the built-in `GITEA_TOKEN`
|
||||
# carries `"creator": null`. A real verdict read back `creator=timothy`; this job's own
|
||||
# exemption read back `creator=null`.
|
||||
#
|
||||
# FIRST measured on 1.25.4; RE-MEASURED on 1.27.1, 2026-09-02 (ersatztv#869) — this is the
|
||||
# claim the H10 allow-list rests on, which is why #869 prioritised it. It is NOT the last
|
||||
# 1.25.4-dated load-bearing claim: the `pull_request_target` half the retarget fence relies
|
||||
# on is still 1.25.4-dated too, deliberately (`ci.verdict-write-retarget-fence`).
|
||||
# Four merged PR heads read back independently (#903, #884, #902,
|
||||
# #899): on every one exactly ONE status carries `.creator.login` — `review-verdict/h10`,
|
||||
# `timothy`, the one `post-review-verdict.sh` writes — and EVERY other row on that head
|
||||
# carries `creator: null`. It holds on BOTH endpoints, combined `/commits/{sha}/status` and
|
||||
# list `/statuses/{sha}`, so this guard and `page_statuses` see the same attribution. The
|
||||
# two differ only in HOW MANY rows come back, which is not an attribution difference: the
|
||||
# combined endpoint is latest-per-context (15 rows on each of the four) and the list
|
||||
# endpoint returns every row ever posted (43-63 across them).
|
||||
#
|
||||
# THE MECHANISM, so this is re-derivable without a live PR. The INTUITIVE version is worth
|
||||
# recording as WRONG, because it is the one a reader will re-derive:
|
||||
# `creator: null` does NOT mean `CreatorID == 0`. At v1.27.1 an Actions job
|
||||
# authenticates as the SYNTHETIC Actions user, `ActionsUserID = -2`
|
||||
# (`models/user/user_system.go`), which is never a row in the `user` table, and
|
||||
# `NewCommitStatus` stores `CreatorID = opts.Creator.ID` — so an Actions-posted status
|
||||
# stores -2, NOT 0. `services/convert/status.go`'s `ToCommitStatus` therefore TAKES its
|
||||
# `if status.CreatorID != 0` branch and the null comes from the next line:
|
||||
# `creator, _ := user_model.GetUserByID(ctx, status.CreatorID)` is a bare lookup with NO
|
||||
# system-user mapping (contrast `GetPossibleUserByID`, which handles `id < 0` explicitly),
|
||||
# so -2 misses, THE ERROR IS DISCARDED, and `ToUser(ctx, nil, nil)` returns nil.
|
||||
#
|
||||
# It is still a property of the SERIALIZER, which is why it cannot differ between the two
|
||||
# endpoints — the combined endpoint reaches the same `ToCommitStatus`.
|
||||
#
|
||||
# THE CONSEQUENCE FOR THIS GUARD, which the wrong mechanism hid: `creator: null` does not
|
||||
# mean "a machine wrote this". Any row whose creator account was later DELETED serializes
|
||||
# null by the same path. That does NOT weaken this guard, and the reason is the direction
|
||||
# of the test below: it short-circuits only on a POSITIVE allow-list match, so both a
|
||||
# machine write and a vanished author land on RE-DERIVE, the safe branch. It would break a
|
||||
# guard written the other way round, which is one more reason not to invert it.
|
||||
#
|
||||
# BOTH conditions are required, and the DIRECTION of the test is the point: we short-circuit
|
||||
# only on something POSITIVELY identified as a human verdict. Anything else, including anything
|
||||
# we do not recognise, is RE-DERIVED. Written the other way round ("skip if it looks
|
||||
@@ -1381,10 +1446,20 @@ jobs:
|
||||
# credential, and reachable from any workflow because secrets are a per-repo store.
|
||||
#
|
||||
# What this STILL does not claim: the test asks "was this POSTed by an account we accept
|
||||
# verdicts from", NOT "by someone who actually reviewed it". `ETV_STATUS_AUTH`'s registry
|
||||
# credential can no longer POST a status at all (#697, fixed by scoping it off
|
||||
# `write:repository` — see `ci.actions-credential-scoping`), and `RENOVATE_TOKEN` is now
|
||||
# re-derived rather than inherited; but an ALLOW-LISTED collaborator's own token can still
|
||||
# verdicts from", NOT "by someone who actually reviewed it". The registry credential
|
||||
# `docker-build.yml` used to pass as `ETV_STATUS_AUTH` can no longer POST a status at all
|
||||
# (#697, fixed by scoping it off `write:repository` — see `ci.actions-credential-scoping`),
|
||||
# and since #885 that route materialises no STORED credential to refuse: nothing on the
|
||||
# `pull_request` route names a stored secret and the status read is anonymous
|
||||
# (`ci.pr-route-carries-no-stored-credential`). The INJECTED `GITEA_TOKEN` is still handed
|
||||
# to every job there and STAYS IN THIS RESIDUAL SET: `docker-build.yml`'s workflow-level
|
||||
# `permissions:` (`code: read`) NARROWS it for the COMMITTED file only, and on the
|
||||
# `pull_request` route the head supplies that file and can delete the block — with the
|
||||
# owner-level Actions default at `permissive` (`ci.actions-credential-scoping`) that alone
|
||||
# yields a write-capable token. Removing the stored secrets does not touch it.
|
||||
# `RENOVATE_TOKEN` is now re-derived rather than inherited.
|
||||
#
|
||||
# But an ALLOW-LISTED collaborator's own token can still
|
||||
# POST a success with an attacker-chosen `Review-verdict:` description, which this guard then
|
||||
# preserves. That is provenance, not authentication — branch protection binds the context
|
||||
# NAME, not its issuer, and Gitea offers no way to restrict who may post a context.
|
||||
@@ -1453,7 +1528,7 @@ jobs:
|
||||
pre_state=$ex_state
|
||||
pre_creator=$ex_creator
|
||||
pre_desc=$ex_desc
|
||||
# THE ID IS PART OF THE SNAPSHOT (ersatztv#849, round 2). The triple cannot see a
|
||||
# THE ID IS PART OF THE SNAPSHOT (ersatztv#849). The triple cannot see a
|
||||
# REPLACEMENT whose text matches, and the two sentinels are byte-identical by design, so
|
||||
# "another run replaced this row while we classified" was invisible for exactly the rows
|
||||
# where it matters most. Where the server does not send an id both sides are empty and the
|
||||
@@ -1469,7 +1544,7 @@ jobs:
|
||||
# this line — creator, allow-list and description together — is what says so. A separate
|
||||
# `::warning::` annotation was tried and WITHDRAWN: it duplicated all three values from
|
||||
# this line, its only delta was a severity whose rendering could not be probed, and it
|
||||
# produced three defects across three review rounds (a message that read "from 'timothy',
|
||||
# produced three successive defects (a message that read "from 'timothy',
|
||||
# which is NOT in H10_REVIEWERS ('timothy')", an unescaped API value interpolated into a
|
||||
# workflow command, and a test that asserted its absence on a path that exits two guards
|
||||
# earlier and so could never fail). One line that is always right beats two where the
|
||||
@@ -1502,7 +1577,7 @@ jobs:
|
||||
if [ "$ex_unverified" = yes ]; then
|
||||
echo "${CONTEXT} on ${SHA:0:7} carries the unverified-write sentinel from an earlier run — reconciling it against the per-POST status history."
|
||||
page_statuses
|
||||
# THE WITNESS: THE SENTINEL'S OWN ROW MUST BE IN THE HISTORY (ersatztv#849, round 2).
|
||||
# THE WITNESS: THE SENTINEL'S OWN ROW MUST BE IN THE HISTORY (ersatztv#849).
|
||||
# `ex_unverified=yes` means the COMBINED endpoint just returned the sentinel for this
|
||||
# sha, and `/statuses/{sha}` keeps one row per POST — so a complete history that does
|
||||
# NOT contain it, empty ones included, is a response that cannot be true. The post-write
|
||||
@@ -1517,7 +1592,7 @@ jobs:
|
||||
# PERMANENT sentinel. This asks for a SPECIFIC row already known to exist, and failing it
|
||||
# carries the sentinel forward for THIS run only — the next run retries.
|
||||
# THE WITNESS IS THE CURRENT ROW'S ID, not merely a row with the right text
|
||||
# (ersatztv#849 round 3). Matching on description alone is satisfied by an OLDER
|
||||
# (ersatztv#849). Matching on description alone is satisfied by an OLDER
|
||||
# identical sentinel, which is precisely what a fixed point produces: with S1 and a
|
||||
# buried human verdict below the CURRENT S2, a read carrying only S1 satisfies the
|
||||
# witness, `buried` sees nothing, the sentinel clears, and the verdict ends up below the
|
||||
@@ -1527,7 +1602,7 @@ jobs:
|
||||
# `$ex_id` COMES FROM THE COMBINED READ, so it names the row that is current right now.
|
||||
# When the server omits it — not observed on this instance, where every row carries one —
|
||||
# there is nothing to match on and the check degrades to the description, which is the
|
||||
# pre-round-3 behaviour rather than a new hole.
|
||||
# description-only behaviour rather than a new hole.
|
||||
if [ -n "$ex_id" ]; then
|
||||
witness=$(printf '%s' "$ph_rows" | jq -r --arg c "$CONTEXT" --argjson wid "$ex_id" \
|
||||
'[.[] | select(type == "object")
|
||||
@@ -1635,10 +1710,10 @@ jobs:
|
||||
for v in "$n_protected" "$n_not_manifest" "$n_not_docs"; do
|
||||
case "$v" in
|
||||
''|*[!0-9]*)
|
||||
# REPLACES, for the reason every other refusal here does (ersatztv#849 round 3): this
|
||||
# REPLACES, for the reason every other refusal here does (ersatztv#849): this
|
||||
# run has already DECLINED to inherit whatever the head carries, so exiting without
|
||||
# writing leaves that row authoritative. The old message said no status "will be
|
||||
# written", which was true and beside the point — the question is what is standing.
|
||||
# writing leaves that row authoritative. A message saying no status "will be
|
||||
# written" is true and beside the point — the question is what is standing.
|
||||
replace_unknown_and_die "A path predicate returned '${v}' instead of a count for ${SHA:0:7}, so the classifier is not operating and this run cannot say what belongs on this head." ;;
|
||||
esac
|
||||
done
|
||||
@@ -1710,8 +1785,8 @@ jobs:
|
||||
state=success
|
||||
desc="Exempt: $reason"
|
||||
elif [ "$ex_repair" = yes ]; then
|
||||
# CARRY THE SENTINEL FORWARD. This branch exists because the first version of it did not,
|
||||
# and cold review reproduced the consequence: refusing the exemption but posting the
|
||||
# CARRY THE SENTINEL FORWARD. This branch exists because the consequence of omitting it
|
||||
# was reproduced: refusing the exemption but posting the
|
||||
# GENERIC pending description overwrote the very sentinel the refusal depends on, so the
|
||||
# next run saw an ordinary machine `pending`, re-derived it, and posted `success` — burying
|
||||
# the human rejection two events after the repair instead of one. The block has to be a
|
||||
@@ -1743,12 +1818,12 @@ jobs:
|
||||
# HIGH-WATER MARK for the post-write verification (ersatztv#706 race 2). Taken FIRST — before
|
||||
# the re-read below, before the fence, before the POST — and the ORDER IS THE POINT.
|
||||
#
|
||||
# An earlier version captured it just before the POST, "as late as possible". Cold review
|
||||
# caught that as a High: everything between the re-read and a late mark is a blind gap. A
|
||||
# Capturing it just before the POST, "as late as possible", is WRONG, and badly so:
|
||||
# everything between the re-read and a late mark is a blind gap. A
|
||||
# human verdict landing there is invisible to the re-read (which already happened) AND
|
||||
# excluded from the post-write check (its id is BELOW a mark taken afterwards), so it is
|
||||
# silently overwritten with no repair. That gap spans the entire retarget re-count — up to 20
|
||||
# timeline round-trips — so it was far wider than the one-round-trip residual being claimed.
|
||||
# timeline round-trips — far wider than a one-round-trip residual.
|
||||
#
|
||||
# Taking the mark first closes the read side completely: any row newer than the mark is caught
|
||||
# either by the re-read (abstain, post nothing) or by the post-write check (repair). There is
|
||||
@@ -1829,13 +1904,11 @@ jobs:
|
||||
max_id_before=-1
|
||||
fi
|
||||
|
||||
# MARK ANY WRITE NOTHING CAN VERIFY (ersatztv#849 route 1). The heading said "withhold the
|
||||
# EXEMPTION" while the block downgrades every re-derivable state, which is narrower than
|
||||
# what it does.
|
||||
# MARK ANY WRITE NOTHING CAN VERIFY (ersatztv#849 route 1).
|
||||
#
|
||||
# EVERY RE-DERIVABLE WRITE IS DOWNGRADED, not only `success` (corrected in round 2). An
|
||||
# earlier version restricted this to the exemption, reasoning that a sticky generic
|
||||
# `pending` "withholds nothing, since an unreviewed PR is blocked already". That analysed
|
||||
# EVERY RE-DERIVABLE WRITE IS DOWNGRADED, not only `success`. Restricting
|
||||
# this to the exemption, on the reasoning that a sticky generic
|
||||
# `pending` "withholds nothing, since an unreviewed PR is blocked already", analyses
|
||||
# the wrong PR. The damaging case is a PR that IS exemptible and only got the generic
|
||||
# `pending` from a transient enumeration failure: the generic description carries no
|
||||
# marker, the post-write check below does not run without a mark, so a human verdict
|
||||
@@ -1892,17 +1965,17 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# A SENTINEL THAT APPEARED MID-RUN (ersatztv#706, round-3 review). The re-read above recomputes
|
||||
# A SENTINEL THAT APPEARED MID-RUN (ersatztv#706). The re-read above recomputes
|
||||
# `ex_repair`, and until this guard existed nothing downstream read it: the POST writes the
|
||||
# `$state` frozen at classification time, so a STALE OVERLAPPING RUN would post its `success`
|
||||
# straight over a sentinel another run had just written — burying a human rejection, with no
|
||||
# repair (the human row is below this run's mark) and no log. That fails toward SUCCESS, so it
|
||||
# was not covered by the "repair fails toward pending" residual; it is the exact outcome this
|
||||
# whole change exists to prevent, reached through the run overlap this branch itself measured.
|
||||
# whole change exists to prevent, reached through a measured run overlap.
|
||||
#
|
||||
# THE RULE IS "NEVER REPLACE A SENTINEL WITH A NON-SENTINEL", not "never overwrite it with a
|
||||
# success". A first draft of this guard tested `state = success`, which is one branch too
|
||||
# narrow: a run can reach the POST on `state=pending` carrying the GENERIC description — most
|
||||
# success". Testing `state = success` here is one branch too narrow: a run can reach
|
||||
# the POST on `state=pending` carrying the GENERIC description — most
|
||||
# realistically after a transient enumeration failure (`complete != yes`) — and that run
|
||||
# passes a success-only guard, passes the fence, and overwrites the sentinel with ordinary
|
||||
# text. The next run then sees no sentinel, re-derives, and posts `success`: the same buried
|
||||
@@ -1967,7 +2040,7 @@ jobs:
|
||||
# instead of a re-derivable description. Letting `pending` through here is still right — the
|
||||
# alternative strands every PR whenever the timeline is unreadable — but the masking itself
|
||||
# remains a cost, so the trade is "immediate block, one repaired write", not "free".
|
||||
# ABSTAINING IS A HANDOFF ONLY WHEN THERE IS NOTHING TO HAND OFF (ersatztv#849 round 3).
|
||||
# ABSTAINING IS A HANDOFF ONLY WHEN THERE IS NOTHING TO HAND OFF (ersatztv#849).
|
||||
# The two arms below are right not to write their CLASSIFICATION — it was computed against
|
||||
# a base or a head the PR may no longer have, and the mutation that invalidated it has
|
||||
# already queued a successor. But when this run DECLINED to inherit a row the head carries,
|
||||
@@ -2037,13 +2110,12 @@ jobs:
|
||||
# in the helper that may be stronger than the reconcilable sentinel. It can only ever
|
||||
# withhold an exemption.
|
||||
mark_desc="$UNVERIFIED_DESC"
|
||||
# `$desc` ALONE, because the classification has already collected the others. Round 8
|
||||
# promoted on `$pre_desc` and `$ex_desc` as well, and both are unreachable as a sole
|
||||
# `$desc` ALONE, because the classification has already collected the others. Promoting
|
||||
# on `$pre_desc` and `$ex_desc` as well adds nothing — both are unreachable as a sole
|
||||
# cause: a repair sentinel at the FIRST read sets `ex_repair`, which forces
|
||||
# `desc="$REPAIR_DESC"`; one arriving mid-run is caught by the sentinel guard unless this
|
||||
# run is itself writing that string, which is the same condition. Keeping them meant two
|
||||
# clauses no fixture could ever distinguish — the shape this branch has spent several
|
||||
# rounds either proving or declaring, and here the honest answer is that they are
|
||||
# run is itself writing that string, which is the same condition. Keeping them means two
|
||||
# clauses no fixture could ever distinguish, and the honest answer is that they are
|
||||
# redundant rather than unprovable. `$desc` needs no state test: it is this run''s own
|
||||
# decision, and it is `pending` whenever it is that string.
|
||||
if [ "$desc" = "$REPAIR_DESC" ]; then mark_desc="$REPAIR_DESC"; fi
|
||||
@@ -2081,11 +2153,11 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
if { [ "$retargets_before_ok" != yes ] || [ "$rt_ok" != yes ]; } && [ "$state" = "success" ]; then
|
||||
# ABSTAINING HERE WAS A FAIL-OPEN WHEN THE HEAD ALREADY CARRIED A ROW (ersatztv#849,
|
||||
# round 2). This branch is reached only after the classification DECLINED to inherit
|
||||
# ABSTAINING HERE WAS A FAIL-OPEN WHEN THE HEAD ALREADY CARRIED A ROW (ersatztv#849).
|
||||
# This branch is reached only after the classification DECLINED to inherit
|
||||
# whatever `review-verdict/h10` the head carries — that is why it is re-deriving — so
|
||||
# posting nothing leaves the declined row current. The old message said the context
|
||||
# "stays absent", which is true only for a head that had none; on a head carrying a
|
||||
# posting nothing leaves the declined row current. A message saying the context
|
||||
# "stays absent" is true only for a head that had none; on a head carrying a
|
||||
# machine or off-list `success` it is the opposite of what happens, and no retarget or
|
||||
# push need have occurred, so no successor run is guaranteed either.
|
||||
#
|
||||
@@ -2243,7 +2315,7 @@ jobs:
|
||||
# over — it carries `creator: null` (an Actions-token POST, measured; see the provenance
|
||||
# note above) and its description is `Exempt: …`, not `Review-verdict:` — so the count is
|
||||
# of human verdicts that did not exist when the mark was taken.
|
||||
# TWO row shapes count as "something raced this write", not one (round-5 review).
|
||||
# TWO row shapes count as "something raced this write", not one.
|
||||
#
|
||||
# (a) a HUMAN verdict — non-null creator, `Review-verdict:` description;
|
||||
# (b) a machine SENTINEL — null creator, description exactly `$REPAIR_DESC`.
|
||||
@@ -2465,8 +2537,8 @@ jobs:
|
||||
# a machine-written `success` is re-derived, not inherited, so that successor changes the
|
||||
# answer. A retarget landing before it is caught here.
|
||||
#
|
||||
# WHAT THAT BUYS IS TRANSIENT INSTEAD OF PERMANENT, not "the green never stands" — which is
|
||||
# what this comment claimed until round-2 review. The `success` is live between its POST
|
||||
# WHAT THAT BUYS IS TRANSIENT INSTEAD OF PERMANENT, not "the green never stands".
|
||||
# The `success` is live between its POST
|
||||
# and the repair below, including the timeline round trips in between, so branch protection
|
||||
# or an already-scheduled auto-merge can observe it. THAT WINDOW GREW WITH #870 and the
|
||||
# sentence is dated accordingly: the walk in between is now 20 requests rather than ~2,
|
||||
|
||||
@@ -23,7 +23,8 @@ public static class ChannelGraphicsDefaults
|
||||
{
|
||||
// HLS Direct is skipped because ErsatzTV is not transcoding there -- there is no frame
|
||||
// pipeline to draw into, and the editor disables the toggle for the same reason. Identity is
|
||||
// the element's filename, never its user-editable Name (the #67 lesson).
|
||||
// the element's full seeded path (`GraphicsElementDefaults.OnNowNextSeededPath`), never its
|
||||
// user-editable Name (the #67 lesson, sharpened from filename to full path by #568).
|
||||
if (channel.StreamingMode is StreamingMode.HttpLiveStreamingDirect)
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -47,8 +47,13 @@ public class UpdateChannelHandler(
|
||||
{
|
||||
Either<BaseError, string> resolvedLogo = await ResolveLogoPath(request, cancellationToken);
|
||||
return await resolvedLogo.Match(
|
||||
Right: async logoPath => Right<BaseError, ChannelViewModel>(
|
||||
await ApplyUpdateRequest(dbContext, c, request, logoPath, cancellationToken)),
|
||||
Right: logoPath =>
|
||||
ApplyUpdateRequestTranslatingLostRace(
|
||||
dbContext,
|
||||
c,
|
||||
request,
|
||||
logoPath,
|
||||
cancellationToken),
|
||||
Left: e => Task.FromResult(Left<BaseError, ChannelViewModel>(e)));
|
||||
},
|
||||
Fail: errors => Task.FromResult(Left<BaseError, ChannelViewModel>(errors.Join())));
|
||||
@@ -76,6 +81,56 @@ public class UpdateChannelHandler(
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Validation and the write are two statements, not one atomic step: RefreshGraphicsElements
|
||||
// deletes elements whose template file is gone, and a delete landing between the two turns the
|
||||
// join insert back into the FK violation the validator exists to prevent -- the unhandled 500
|
||||
// again (#568). A transaction does not close that window either: neither provider locks the rows
|
||||
// the validator merely READ, so the concurrent delete still commits. Ask the existence question
|
||||
// again on the failure path instead, and return the same 422 the validator would have returned;
|
||||
// a DbUpdateException from any other cause keeps its own exception rather than being reported as
|
||||
// a client error.
|
||||
//
|
||||
// What is re-asked is the WHOLE of Validate, not the graphics-element half: every FK on this
|
||||
// full-replace DTO -- FFmpegProfileId, WatermarkId, FallbackFillerId, MirrorSourceChannelId and
|
||||
// the graphics element ids -- is written by ApplyUpdateRequest and can lose the same race, and a
|
||||
// recovery path that names its fields one by one silently omits the next FK the DTO gains.
|
||||
// Re-running the validator set is what keeps the two paths from drifting: a check added to
|
||||
// Validate is covered here by construction.
|
||||
private async Task<Either<BaseError, ChannelViewModel>> ApplyUpdateRequestTranslatingLostRace(
|
||||
TvContext dbContext,
|
||||
Channel channel,
|
||||
UpdateChannel request,
|
||||
string logoPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Right<BaseError, ChannelViewModel>(
|
||||
await ApplyUpdateRequest(dbContext, channel, request, logoPath, cancellationToken));
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// a fresh context: the failed save left the original one tracking the changes that
|
||||
// could not be written, so the same query there could be answered from those. The
|
||||
// channel entity is still the tracked one from the failed context, which Validate reads
|
||||
// only in memory (MirrorSourceMustBeValid's own-playout count) and never re-queries.
|
||||
await using TvContext recheckContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Channel> recheck =
|
||||
await Validate(recheckContext, request, channel, cancellationToken);
|
||||
|
||||
Option<BaseError> maybeError = recheck.Match(
|
||||
Succ: _ => Option<BaseError>.None,
|
||||
Fail: errors => Some(errors.Join()));
|
||||
|
||||
foreach (BaseError error in maybeError)
|
||||
{
|
||||
return Left<BaseError, ChannelViewModel>(error);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ChannelViewModel> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
Channel c,
|
||||
@@ -229,14 +284,15 @@ public class UpdateChannelHandler(
|
||||
.Apply((_, _, _, _, _) => channel);
|
||||
|
||||
// combine the page-only Group rule plus the FK existence checks (FFmpeg profile / watermark /
|
||||
// fallback filler) with the channel validation; splitting keeps tuple arity within
|
||||
// LanguageExt's supported applicative range while still accumulating all errors
|
||||
// fallback filler / graphics elements) with the channel validation; splitting keeps tuple
|
||||
// arity within LanguageExt's supported applicative range while still accumulating all errors
|
||||
return (ValidateGroup(request.Group),
|
||||
await FFmpegProfileMustExist(dbContext, request, cancellationToken),
|
||||
await WatermarkMustExist(dbContext, request, cancellationToken),
|
||||
await FillerPresetMustExist(dbContext, request, cancellationToken),
|
||||
await GraphicsElementIdsMustExist(dbContext, request, cancellationToken),
|
||||
channelValidation)
|
||||
.Apply((_, _, _, _, c) => c);
|
||||
.Apply((_, _, _, _, _, c) => c);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, int>> FFmpegProfileMustExist(
|
||||
@@ -295,6 +351,28 @@ public class UpdateChannelHandler(
|
||||
return BaseError.New($"Fallback filler {request.FallbackFillerId} does not exist.");
|
||||
}
|
||||
|
||||
// The reconcile in ApplyUpdateRequest blindly Adds a ChannelGraphicsElement for every incoming
|
||||
// id; an id with no matching GraphicsElement row would otherwise hit
|
||||
// FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId at SaveChangesAsync and surface as
|
||||
// an unhandled 500 (there is no global exception filter). Reject it here instead, for parity
|
||||
// with every other FK field on this full-replace DTO (#568). The count cap, the request field
|
||||
// named in the message and the cap on echoed ids all live in Validators.IdsMustExist, shared
|
||||
// with the two UpdateDecoHandler twins so the three cannot drift apart.
|
||||
private static Task<Validation<BaseError, Unit>> GraphicsElementIdsMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateChannel request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validators.IdsMustExist(
|
||||
request,
|
||||
r => r.GraphicsElementIds,
|
||||
"Graphics element",
|
||||
idsAreConsumed: true,
|
||||
(ids, token) => dbContext.GraphicsElements
|
||||
.Where(e => ids.Contains(e.Id))
|
||||
.Select(e => e.Id)
|
||||
.ToListAsync(token),
|
||||
cancellationToken);
|
||||
|
||||
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
|
||||
TvContext dbContext,
|
||||
UpdateChannel request,
|
||||
|
||||
@@ -22,7 +22,7 @@ public class GetAllGraphicsElementsForApiHandler(IDbContextFactory<TvContext> db
|
||||
.Select(e => new
|
||||
{
|
||||
Vm = ProjectToViewModel(e),
|
||||
BuiltIn = Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName
|
||||
BuiltIn = GraphicsElementDefaults.IsOnNowNext(e.Path, e.Kind)
|
||||
})
|
||||
.OrderBy(x => x.Vm.Name == x.Vm.FileName)
|
||||
.ThenBy(x => x.Vm.Name)
|
||||
|
||||
@@ -19,7 +19,50 @@ public class UpdateDecoHandler(
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Deco> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request, cancellationToken));
|
||||
return await validation.Match(
|
||||
Succ: deco => ApplyUpdateRequestTranslatingLostRace(dbContext, deco, request, cancellationToken),
|
||||
Fail: errors => Task.FromResult(Left<BaseError, Unit>(errors.Join())));
|
||||
}
|
||||
|
||||
// Mirrors UpdateChannelHandler.ApplyUpdateRequestTranslatingLostRace (#568): validation and the
|
||||
// write are two statements, so a concurrent delete of a validated watermark or graphics element
|
||||
// -- RefreshGraphicsElements deletes elements whose template file is gone -- lands the join
|
||||
// insert on the FK violation the validators exist to prevent, as an unhandled 500. A transaction
|
||||
// does not close that window either (neither provider locks the rows the validator merely READ),
|
||||
// so ask the existence questions again on the failure path and return the same 422; a
|
||||
// DbUpdateException from any other cause keeps its own exception.
|
||||
//
|
||||
// The whole of Validate is re-asked rather than a named pair of fields, for the same reason as
|
||||
// the channel twin: a recovery path that enumerates its own fields omits the next one the DTO
|
||||
// gains, while re-running the validator set covers a check added to Validate by construction.
|
||||
private async Task<Either<BaseError, Unit>> ApplyUpdateRequestTranslatingLostRace(
|
||||
TvContext dbContext,
|
||||
Deco existing,
|
||||
UpdateDeco request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await ApplyUpdateRequest(dbContext, existing, request, cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// a fresh context: the failed save left the original one tracking the changes that
|
||||
// could not be written, so the same query there could be answered out of those.
|
||||
await using TvContext recheckContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Deco> recheck = await Validate(recheckContext, request, cancellationToken);
|
||||
|
||||
Option<BaseError> maybeError = recheck.Match(
|
||||
Succ: _ => Option<BaseError>.None,
|
||||
Fail: errors => Some(errors.Join()));
|
||||
|
||||
foreach (BaseError error in maybeError)
|
||||
{
|
||||
return Left<BaseError, Unit>(error);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdateRequest(
|
||||
@@ -31,7 +74,7 @@ public class UpdateDecoHandler(
|
||||
existing.Name = request.Name;
|
||||
|
||||
// watermark
|
||||
bool hasWatermark = request.WatermarkMode is (DecoMode.Override or DecoMode.Merge);
|
||||
bool hasWatermark = ConsumesWatermarkIds(request);
|
||||
existing.WatermarkMode = request.WatermarkMode;
|
||||
existing.UseWatermarkDuringFiller = hasWatermark && request.UseWatermarkDuringFiller;
|
||||
|
||||
@@ -59,7 +102,7 @@ public class UpdateDecoHandler(
|
||||
}
|
||||
|
||||
// graphics elements
|
||||
bool hasGraphicsElements = request.GraphicsElementsMode is (DecoMode.Override or DecoMode.Merge);
|
||||
bool hasGraphicsElements = ConsumesGraphicsElementIds(request);
|
||||
existing.GraphicsElementsMode = request.GraphicsElementsMode;
|
||||
existing.UseGraphicsElementsDuringFiller = hasGraphicsElements && request.UseGraphicsElementsDuringFiller;
|
||||
|
||||
@@ -218,8 +261,64 @@ public class UpdateDecoHandler(
|
||||
UpdateDeco request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await DecoMustExist(dbContext, request, cancellationToken), await ValidateDecoName(dbContext, request),
|
||||
ValidateBreakContent(request))
|
||||
.Apply((deco, _, _) => deco);
|
||||
ValidateBreakContent(request),
|
||||
await WatermarkIdsMustExist(dbContext, request, cancellationToken),
|
||||
await GraphicsElementIdsMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((deco, _, _, _, _) => deco);
|
||||
|
||||
// The mode decides whether an id list is data or dead weight: ApplyUpdateRequest reconciles the
|
||||
// join table only under Override/Merge and Clear()s it otherwise, ignoring the ids entirely. The
|
||||
// validators below read these same two predicates rather than restating the mode test, so a
|
||||
// validator can never reject an id the apply path was going to discard (#568). The SPA sends both
|
||||
// id lists regardless of the mode selector, so that shape arrives from the real editor: a draft
|
||||
// holding an element that has since been deleted must still be able to save the deco back to
|
||||
// Inherit. The predicate is handed to Validators.IdsMustExist rather than short-circuiting the
|
||||
// call, because only the EXISTENCE half belongs to the apply path: a discarded list was still
|
||||
// parsed and materialized out of the request body, so the raw-count cap has to apply under
|
||||
// every mode.
|
||||
private static bool ConsumesWatermarkIds(UpdateDeco request) =>
|
||||
request.WatermarkMode is (DecoMode.Override or DecoMode.Merge);
|
||||
|
||||
private static bool ConsumesGraphicsElementIds(UpdateDeco request) =>
|
||||
request.GraphicsElementsMode is (DecoMode.Override or DecoMode.Merge);
|
||||
|
||||
// Mirrors UpdateChannelHandler.GraphicsElementIdsMustExist (#568): the reconcile in
|
||||
// ApplyUpdateRequest blindly Adds a DecoWatermark/DecoGraphicsElement for every incoming id, and
|
||||
// an id with no matching row hits the FK constraint at SaveChangesAsync and surfaces as an
|
||||
// unhandled 500 (there is no global exception filter). These are top-level fields on
|
||||
// ReplaceDecoRequest, the same position as graphicsElementIds on UpdateChannelRequest -- not the
|
||||
// "deep FK ids nested inside item-list request bodies" carve-out in api-conventions.md. Both go
|
||||
// through Validators.IdsMustExist, the one place the count cap, the request field named in the
|
||||
// message and the cap on echoed ids are written.
|
||||
private static Task<Validation<BaseError, Unit>> WatermarkIdsMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateDeco request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validators.IdsMustExist(
|
||||
request,
|
||||
r => r.WatermarkIds,
|
||||
"Watermark",
|
||||
idsAreConsumed: ConsumesWatermarkIds(request),
|
||||
(ids, token) => dbContext.ChannelWatermarks
|
||||
.Where(w => ids.Contains(w.Id))
|
||||
.Select(w => w.Id)
|
||||
.ToListAsync(token),
|
||||
cancellationToken);
|
||||
|
||||
private static Task<Validation<BaseError, Unit>> GraphicsElementIdsMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateDeco request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validators.IdsMustExist(
|
||||
request,
|
||||
r => r.GraphicsElementIds,
|
||||
"Graphics element",
|
||||
idsAreConsumed: ConsumesGraphicsElementIds(request),
|
||||
(ids, token) => dbContext.GraphicsElements
|
||||
.Where(e => ids.Contains(e.Id))
|
||||
.Select(e => e.Id)
|
||||
.ToListAsync(token),
|
||||
cancellationToken);
|
||||
|
||||
private static Task<Validation<BaseError, Deco>> DecoMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -18,8 +18,7 @@ public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextF
|
||||
/// Rows read per round trip when walking the list-valued (JSON-array) columns on
|
||||
/// <c>SongMetadata</c>, and the ceiling on rows read per request.
|
||||
/// <para>
|
||||
/// These count ACTUAL ROWS, and arriving at that took four tries — each earlier attempt bounded a
|
||||
/// quantity that sounded like rows and was not. A fixed <c>LIMIT</c> budget bounded the RESULT, and
|
||||
/// These count ACTUAL ROWS. A fixed <c>LIMIT</c> budget bounded the RESULT, and
|
||||
/// the pre-filter (allowed to over-match) starved it with rows that could not match. Keyset paging
|
||||
/// with a <c>LIMIT</c> bounded CANDIDATES RETURNED — but a query matching nothing must evaluate
|
||||
/// every eligible row before it can return an empty page, so rows inspected stayed unbounded. A
|
||||
@@ -36,8 +35,7 @@ public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextF
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Be precise about what is bounded: LOGICAL ROWS RETURNED AND MATERIALIZED, and the number of
|
||||
/// round trips. Not physical work, and not bytes.</b> Two things break the stronger reading, and an
|
||||
/// earlier version of this comment asserted it anyway:
|
||||
/// round trips. Not physical work, and not bytes.</b> Two things break the stronger reading:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// MySQL purge lag. Deleted clustered-index records survive until purge runs, and a range
|
||||
@@ -142,7 +140,7 @@ public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextF
|
||||
// over-match, even though the column collation (utf8mb4_0900_ai_ci) is accent-insensitive: the driver
|
||||
// binds the LIKE pattern with a BINARY collation, so the comparison is accent-sensitive in practice.
|
||||
// A hand-typed probe using a LITERAL pattern DOES over-match; that is a different query from the one
|
||||
// this code runs, and mistaking the two is how an earlier revision of the decision record got it wrong.
|
||||
// this code runs, and mistaking the two gives a false read on whether this predicate over-matches.
|
||||
if (source is not null && ContainsNonAscii(query) && IsSqlite(dbContext))
|
||||
{
|
||||
values.AddRange(
|
||||
@@ -472,7 +470,7 @@ public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextF
|
||||
/// ordering key</i>, which positions the scan and never discards a row, whereas a residual
|
||||
/// predicate throws away rows the engine already produced. <c>LIMIT</c> only truncates what
|
||||
/// survives a residual predicate, so with one present it bounds the output rather than the row
|
||||
/// count — which is how every earlier revision scanned past its own bound. With none, <c>LIMIT n</c>
|
||||
/// count — a gap wide enough to scan straight past a nominal row-count bound. With none, <c>LIMIT n</c>
|
||||
/// yields <c>n</c> logical rows. Null payloads are dropped in memory by
|
||||
/// <see cref="ParseElements" />.
|
||||
/// </para>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Linq.Expressions;
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application;
|
||||
|
||||
public static partial class Validators
|
||||
{
|
||||
/// <summary>
|
||||
/// The largest id list a full-replace write path accepts in one of its top-level id fields.
|
||||
/// Deliberately far above any real payload -- the lists it bounds select from tables an
|
||||
/// operator curates by hand (graphics elements, watermarks), where a few dozen rows is a
|
||||
/// large install -- so the bound is a ceiling on abuse, not a product limit anyone can reach
|
||||
/// by using the editor (#568).
|
||||
/// </summary>
|
||||
public const int MaximumIdListCount = 512;
|
||||
|
||||
// A 422 that echoes every rejected id turns an oversized request into an oversized response.
|
||||
// Enough ids to fix the payload by hand, then a count.
|
||||
private const int MaximumReportedMissingIds = 10;
|
||||
|
||||
/// <summary>
|
||||
/// The shared existence check for a top-level list of FK ids on a full-replace request:
|
||||
/// bound the list, resolve which of its ids exist through <paramref name="findExisting" />,
|
||||
/// and reject the rest with a 422 that names the request field it came from.
|
||||
/// </summary>
|
||||
/// <param name="idsAreConsumed">
|
||||
/// Whether the apply path will actually read this list — false where another field of the
|
||||
/// same request (a deco's <c>DecoMode</c>) makes the reconcile discard it. It gates the
|
||||
/// EXISTENCE half only, never the count: a validator may not reject an id the apply path
|
||||
/// was going to throw away, but the raw list was still parsed and materialized out of the
|
||||
/// request body whatever is done with it afterwards, so the cap is the request's bound and
|
||||
/// not the apply path's (#568).
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// The count is taken from the RAW list, before <c>Distinct</c> and before any database
|
||||
/// work: deduplication is not what the request costs. A million-entry list of one repeated
|
||||
/// id parses, allocates and materializes in full whatever the distinct count turns out to
|
||||
/// be, so a cap applied after <c>Distinct</c> would bound the query and leave the request
|
||||
/// itself unbounded.
|
||||
/// </remarks>
|
||||
public static async Task<Validation<BaseError, Unit>> IdsMustExist<T>(
|
||||
T input,
|
||||
Expression<Func<T, List<int>>> expression,
|
||||
string noun,
|
||||
bool idsAreConsumed,
|
||||
Func<List<int>, CancellationToken, Task<List<int>>> findExisting,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string field = GetMemberName(expression);
|
||||
List<int> submitted = expression.Compile()(input) ?? [];
|
||||
|
||||
if (submitted.Count > MaximumIdListCount)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"[{field}] contains {submitted.Count} ids; at most {MaximumIdListCount} are accepted. " +
|
||||
"The whole list is materialized into one existence query and then reconciled against every " +
|
||||
"row already attached, so a longer list turns a single request into unbounded work.");
|
||||
}
|
||||
|
||||
if (!idsAreConsumed)
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
List<int> requested = submitted.Distinct().ToList();
|
||||
if (requested.Count == 0)
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
List<int> existingIds = await findExisting(requested, cancellationToken);
|
||||
|
||||
List<int> missingIds = requested.Except(existingIds).OrderBy(id => id).ToList();
|
||||
if (missingIds.Count == 0)
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
return BaseError.New($"[{field}] {noun}(s) do not exist: {DescribeIds(missingIds)}");
|
||||
}
|
||||
|
||||
private static string DescribeIds(IReadOnlyList<int> ids) =>
|
||||
ids.Count <= MaximumReportedMissingIds
|
||||
? string.Join(", ", ids)
|
||||
: $"{string.Join(", ", ids.Take(MaximumReportedMissingIds))} (and " +
|
||||
$"{ids.Count - MaximumReportedMissingIds} more)";
|
||||
}
|
||||
@@ -359,9 +359,9 @@ public class WatermarkSelectorDecoResolutionTests
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The channel-level fallback is deliberately an INDEPENDENTLY RESOLVABLE `ChannelLogo` watermark whose
|
||||
/// cached file exists. An earlier version of this test gave the fallback the same missing custom path as
|
||||
/// the playout-item watermark, which made it unfalsifiable: a wrongly-widened guard would have fallen
|
||||
/// through to a fallback that also resolved to None, so the assertion held either way.
|
||||
/// cached file exists. Giving the fallback the same missing custom path as the playout-item watermark
|
||||
/// would make the test unfalsifiable: a wrongly-widened guard would fall through to a fallback that also
|
||||
/// resolves to None, so the assertion would hold either way.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Missing_But_Named_Custom_Playout_Item_Watermark_Should_Not_Fall_Through()
|
||||
|
||||
@@ -14,8 +14,10 @@ namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
// different algorithm keyed on the same PlaybackOrder), and
|
||||
// - every order the two engines don't support returns None, so each caller logs its own #70 warning
|
||||
// instead of silently scheduling nothing.
|
||||
// The Scripted engine has no golden (its external-process/HTTP transport is integration-only, #563), so
|
||||
// this direct helper test is the in-process regression net for the shared construction it drives.
|
||||
// The Scripted engine has no golden: its external-process/HTTP transport is permanently outside the
|
||||
// automated suite, and the engine behind it is covered in-process instead (decision
|
||||
// testing.scripted-engine-in-process-net, docs/testing.md -> "Scripted playout coverage"). This direct
|
||||
// helper test is the regression net for the shared construction that engine drives.
|
||||
[TestFixture]
|
||||
public class ContentEnumeratorBuilderTests
|
||||
{
|
||||
|
||||
@@ -1,23 +1,50 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling.Engine;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Characterization of the <see cref="SchedulingEngine" /> build API — the surface a scripted schedule
|
||||
/// drives, one method per <c>ScriptedScheduleController</c> action. Covers content registration
|
||||
/// (<c>AddCollection</c>), the scheduling instructions (<c>AddCount</c>, <c>AddAll</c>,
|
||||
/// <c>AddDuration</c>, <c>PadUntilExact</c>), EPG grouping, per-item history, the no-progress halt, and
|
||||
/// the anchor round-trip that a Continue build restores from.
|
||||
/// <para>
|
||||
/// Deliberately out of scope here: the <c>Cli.Wrap</c> launch of the user-authored script process
|
||||
/// (exit code, timeout, stdout capture) and the Kestrel/HTTP/auth transport it calls back over. Those
|
||||
/// are permanently outside the automated suite; the controller adapter that sits between them and this
|
||||
/// engine is pinned by <c>ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests</c>. Decision:
|
||||
/// <c>testing.scripted-engine-in-process-net</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every fixture here is timezone-independent by construction: it uses only Chronological order plus
|
||||
/// <c>AddCount</c>/<c>AddAll</c>/<c>AddDuration</c>/<c>PadUntilExact</c>, all of which preserve the
|
||||
/// instant. <c>WaitUntil(TimeOnly)</c> and <c>PadUntil(string)</c> read the LOCAL day and time-of-day
|
||||
/// and are therefore excluded. See docs/testing.md → Timezone independence.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class SchedulingEngineTests
|
||||
{
|
||||
private const string ContentKey = "content";
|
||||
private const string CollectionName = "Test Collection";
|
||||
|
||||
// Pinned build window, offset zero: the engine writes PlayoutItem.Start/Finish as UtcDateTime, so every
|
||||
// assertion below is on an instant rather than a wall-clock reading.
|
||||
private static readonly DateTimeOffset Start = new(2026, 1, 15, 6, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Test]
|
||||
public void Continue_Across_Time_Change()
|
||||
{
|
||||
var engine = new SchedulingEngine(
|
||||
Substitute.For<IMediaCollectionRepository>(),
|
||||
Substitute.For<IGraphicsElementRepository>(),
|
||||
Substitute.For<IChannelRepository>(),
|
||||
Substitute.For<ILogger<SchedulingEngine>>());
|
||||
SchedulingEngine engine = NewEngine(Substitute.For<IMediaCollectionRepository>());
|
||||
|
||||
var anchor = new PlayoutAnchor
|
||||
{
|
||||
@@ -25,11 +52,468 @@ public class SchedulingEngineTests
|
||||
};
|
||||
|
||||
var start = new DateTimeOffset(new DateTime(2025, 11, 20), TimeSpan.FromHours(-6));
|
||||
var finish = start.AddDays(1);
|
||||
DateTimeOffset finish = start.AddDays(1);
|
||||
|
||||
engine.BuildBetween(start, finish);
|
||||
|
||||
// should not throw
|
||||
engine.RestoreOrReset(anchor);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddCollection_Then_AddCount_Lays_Items_Back_To_Back()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
engine.AddCount(ContentKey, 4, Option<FillerKind>.None, null, false).ShouldBeTrue();
|
||||
|
||||
List<PlayoutItem> items = engine.GetState().AddedItems;
|
||||
items.Count.ShouldBe(4);
|
||||
|
||||
// chronological order is the collection's release-date order, which is item id order here
|
||||
items.Select(i => i.MediaItemId).ShouldBe([1, 2, 3, 4]);
|
||||
|
||||
items[0].Start.ShouldBe(Start.UtcDateTime);
|
||||
for (var i = 1; i < items.Count; i++)
|
||||
{
|
||||
items[i].Start.ShouldBe(items[i - 1].Finish);
|
||||
}
|
||||
|
||||
foreach (PlayoutItem item in items)
|
||||
{
|
||||
item.FillerKind.ShouldBe(FillerKind.None);
|
||||
item.InPoint.ShouldBe(TimeSpan.Zero);
|
||||
item.OutPoint.ShouldBe(item.Finish - item.Start);
|
||||
item.PlayoutId.ShouldBe(1);
|
||||
}
|
||||
|
||||
// outside an EPG group every item opens its own guide group
|
||||
items.Select(i => i.GuideGroup).ShouldBe([1, 2, 3, 4]);
|
||||
|
||||
// 30 + 45 + 60 + 20 minutes of content
|
||||
TimeSpan scheduled = TimeSpan.FromMinutes(155);
|
||||
items[^1].Finish.ShouldBe(Start.UtcDateTime + scheduled);
|
||||
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start + scheduled);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddAll_Schedules_Every_Item_Once()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
engine.AddAll(ContentKey, Option<FillerKind>.None, null, false).ShouldBeTrue();
|
||||
|
||||
List<PlayoutItem> items = engine.GetState().AddedItems;
|
||||
items.Select(i => i.MediaItemId).ShouldBe([1, 2, 3, 4, 5, 6]);
|
||||
|
||||
// 30 + 45 + 60 + 20 + 90 + 15 minutes
|
||||
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start + TimeSpan.FromMinutes(260));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddDuration_Stops_Before_Overrunning_The_Target()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
engine.AddDuration(
|
||||
ContentKey,
|
||||
"2:00:00",
|
||||
fallback: null,
|
||||
trim: false,
|
||||
discardAttempts: 0,
|
||||
stopBeforeEnd: true,
|
||||
offlineTail: false,
|
||||
Option<FillerKind>.None,
|
||||
customTitle: null,
|
||||
disableWatermarks: false)
|
||||
.ShouldBeTrue();
|
||||
|
||||
DateTimeOffset target = Start.AddHours(2);
|
||||
List<PlayoutItem> items = engine.GetState().AddedItems;
|
||||
|
||||
// 30 + 45 fits; the third item (60) does not, and nothing is trimmed
|
||||
items.Select(i => i.MediaItemId).ShouldBe([1, 2]);
|
||||
items[^1].Finish.ShouldBe(Start.UtcDateTime + TimeSpan.FromMinutes(75));
|
||||
items[^1].Finish.ShouldBeLessThan(target.UtcDateTime);
|
||||
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start + TimeSpan.FromMinutes(75));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddDuration_Trims_The_Last_Item_When_Trim_Is_Set()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
engine.AddDuration(
|
||||
ContentKey,
|
||||
"2:00:00",
|
||||
fallback: null,
|
||||
trim: true,
|
||||
discardAttempts: 0,
|
||||
stopBeforeEnd: true,
|
||||
offlineTail: false,
|
||||
Option<FillerKind>.None,
|
||||
customTitle: null,
|
||||
disableWatermarks: false)
|
||||
.ShouldBeTrue();
|
||||
|
||||
DateTimeOffset target = Start.AddHours(2);
|
||||
List<PlayoutItem> items = engine.GetState().AddedItems;
|
||||
|
||||
items.Select(i => i.MediaItemId).ShouldBe([1, 2, 3]);
|
||||
items[^1].Finish.ShouldBe(target.UtcDateTime);
|
||||
items[^1].OutPoint.ShouldBe(items[^1].Finish - items[^1].Start);
|
||||
items[^1].OutPoint.ShouldBe(TimeSpan.FromMinutes(45));
|
||||
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(target);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PadUntilExact_Fills_To_The_Target_Instant()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
DateTimeOffset target = Start.AddHours(2);
|
||||
|
||||
engine.PadUntilExact(
|
||||
ContentKey,
|
||||
target,
|
||||
fallback: null,
|
||||
trim: true,
|
||||
discardAttempts: 0,
|
||||
stopBeforeEnd: true,
|
||||
offlineTail: false,
|
||||
Option<FillerKind>.None,
|
||||
customTitle: null,
|
||||
disableWatermarks: false)
|
||||
.ShouldBeTrue();
|
||||
|
||||
List<PlayoutItem> items = engine.GetState().AddedItems;
|
||||
items.Count.ShouldBeGreaterThan(0);
|
||||
items[0].Start.ShouldBe(Start.UtcDateTime);
|
||||
items[^1].Finish.ShouldBe(target.UtcDateTime);
|
||||
|
||||
// the target is an instant, so a machine-local offset must not move it
|
||||
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(target);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddDuration_Rejects_An_Unparseable_Duration()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
engine.AddDuration(
|
||||
ContentKey,
|
||||
"not-a-duration",
|
||||
fallback: null,
|
||||
trim: false,
|
||||
discardAttempts: 0,
|
||||
stopBeforeEnd: true,
|
||||
offlineTail: false,
|
||||
Option<FillerKind>.None,
|
||||
customTitle: null,
|
||||
disableWatermarks: false)
|
||||
.ShouldBeFalse();
|
||||
|
||||
engine.GetState().AddedItems.ShouldBeEmpty();
|
||||
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddDuration_Rejects_Offline_Tail_Without_Stop_Before_End()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
engine.AddDuration(
|
||||
ContentKey,
|
||||
"2:00:00",
|
||||
fallback: null,
|
||||
trim: false,
|
||||
discardAttempts: 0,
|
||||
stopBeforeEnd: false,
|
||||
offlineTail: true,
|
||||
Option<FillerKind>.None,
|
||||
customTitle: null,
|
||||
disableWatermarks: false)
|
||||
.ShouldBeFalse();
|
||||
|
||||
engine.GetState().AddedItems.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[TestCase("add_all")]
|
||||
[TestCase("add_count")]
|
||||
[TestCase("add_duration")]
|
||||
[TestCase("pad_to_next")]
|
||||
[TestCase("pad_until")]
|
||||
[TestCase("pad_until_exact")]
|
||||
public async Task Unknown_Content_Key_Returns_False_And_Schedules_Nothing(string instruction)
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
const string Unknown = "no-such-key";
|
||||
|
||||
bool result = instruction switch
|
||||
{
|
||||
"add_all" => engine.AddAll(Unknown, Option<FillerKind>.None, null, false),
|
||||
"add_count" => engine.AddCount(Unknown, 1, Option<FillerKind>.None, null, false),
|
||||
"add_duration" => engine.AddDuration(
|
||||
Unknown,
|
||||
"1:00:00",
|
||||
null,
|
||||
false,
|
||||
0,
|
||||
true,
|
||||
false,
|
||||
Option<FillerKind>.None,
|
||||
null,
|
||||
false),
|
||||
"pad_to_next" => engine.PadToNext(
|
||||
Unknown,
|
||||
15,
|
||||
null,
|
||||
false,
|
||||
0,
|
||||
true,
|
||||
false,
|
||||
Option<FillerKind>.None,
|
||||
null,
|
||||
false),
|
||||
"pad_until" => engine.PadUntil(
|
||||
Unknown,
|
||||
"07:00",
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
0,
|
||||
true,
|
||||
false,
|
||||
Option<FillerKind>.None,
|
||||
null,
|
||||
false),
|
||||
"pad_until_exact" => engine.PadUntilExact(
|
||||
Unknown,
|
||||
Start.AddHours(1),
|
||||
null,
|
||||
false,
|
||||
0,
|
||||
true,
|
||||
false,
|
||||
Option<FillerKind>.None,
|
||||
null,
|
||||
false),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(instruction))
|
||||
};
|
||||
|
||||
result.ShouldBeFalse();
|
||||
|
||||
// the false is only meaningful if nothing was scheduled behind it
|
||||
engine.GetState().AddedItems.ShouldBeEmpty();
|
||||
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Collection_Is_Skipped()
|
||||
{
|
||||
var repository = Substitute.For<IMediaCollectionRepository>();
|
||||
repository.GetCollectionItemsByName(CollectionName, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<MediaItem>());
|
||||
|
||||
SchedulingEngine engine = ResetEngine(repository);
|
||||
await engine.AddCollection(ContentKey, CollectionName, PlaybackOrder.Chronological, CancellationToken.None);
|
||||
|
||||
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeFalse();
|
||||
engine.GetState().AddedItems.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Filler_Kind_And_Custom_Title_Reach_The_Item()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
engine.AddCount(ContentKey, 1, FillerKind.PreRoll, "Bumper", true).ShouldBeTrue();
|
||||
|
||||
PlayoutItem item = engine.GetState().AddedItems.Single();
|
||||
item.FillerKind.ShouldBe(FillerKind.PreRoll);
|
||||
item.CustomTitle.ShouldBe("Bumper");
|
||||
item.DisableWatermarks.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Guide_Group_Is_Locked_Across_An_Epg_Group()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
engine.LockGuideGroup(advance: true, customTitle: "Block");
|
||||
engine.AddCount(ContentKey, 3, Option<FillerKind>.None, null, false).ShouldBeTrue();
|
||||
engine.UnlockGuideGroup();
|
||||
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeTrue();
|
||||
|
||||
List<PlayoutItem> items = engine.GetState().AddedItems;
|
||||
items.Count.ShouldBe(4);
|
||||
|
||||
List<PlayoutItem> grouped = items.Take(3).ToList();
|
||||
grouped.Select(i => i.GuideGroup).Distinct().Count().ShouldBe(1);
|
||||
grouped.ShouldAllBe(i => i.CustomTitle == "Block");
|
||||
|
||||
// unlocking resumes per-item advancement from the group's number
|
||||
items[3].GuideGroup.ShouldBe(grouped[0].GuideGroup + 1);
|
||||
items[3].CustomTitle.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task History_Is_Recorded_Per_Item()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
engine.AddCount(ContentKey, 3, Option<FillerKind>.None, null, false).ShouldBeTrue();
|
||||
|
||||
List<PlayoutItem> items = engine.GetState().AddedItems;
|
||||
List<PlayoutHistory> history = engine.GetState().AddedHistory;
|
||||
|
||||
history.Count.ShouldBe(items.Count);
|
||||
|
||||
string expectedKey = HistoryDetails.KeyForSchedulingContent(ContentKey, PlaybackOrder.Chronological);
|
||||
for (var i = 0; i < history.Count; i++)
|
||||
{
|
||||
history[i].Key.ShouldBe(expectedKey);
|
||||
history[i].PlayoutId.ShouldBe(1);
|
||||
history[i].PlaybackOrder.ShouldBe(PlaybackOrder.Chronological);
|
||||
history[i].Index.ShouldBe(i);
|
||||
history[i].When.ShouldBe(items[i].Start);
|
||||
history[i].Finish.ShouldBe(items[i].Finish);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Is_Done_Throws_After_Twenty_Consecutive_Calls_Without_Progress()
|
||||
{
|
||||
SchedulingEngine engine = ResetEngine(Substitute.For<IMediaCollectionRepository>());
|
||||
ISchedulingEngineState state = engine.GetState();
|
||||
|
||||
// the first read establishes the baseline; each of the next 19 increments the no-progress counter
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
state.IsDone.ShouldBeFalse();
|
||||
}
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => _ = state.IsDone);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Is_Done_Counter_Resets_When_Time_Advances()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
ISchedulingEngineState state = engine.GetState();
|
||||
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
state.IsDone.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// one instruction that advances CurrentTime clears the counter, so the budget starts over
|
||||
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeTrue();
|
||||
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
state.IsDone.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Anchor_Round_Trips_Through_Restore()
|
||||
{
|
||||
SchedulingEngine first = await ResetEngineWithCollection();
|
||||
first.AddCount(ContentKey, 2, Option<FillerKind>.None, null, false).ShouldBeTrue();
|
||||
|
||||
List<PlayoutItem> firstItems = first.GetState().AddedItems;
|
||||
int lastGuideGroup = firstItems[^1].GuideGroup;
|
||||
|
||||
PlayoutAnchor anchor = first.GetAnchor();
|
||||
anchor.NextStart.ShouldBe(firstItems[^1].Finish);
|
||||
|
||||
SchedulingEngine second = NewEngine(CollectionRepository());
|
||||
second.WithPlayoutId(1)
|
||||
.WithMode(PlayoutBuildMode.Continue)
|
||||
.WithSeed(0)
|
||||
.BuildBetween(Start, Start.AddDays(1))
|
||||
.WithReferenceData(EmptyReferenceData())
|
||||
.RestoreOrReset(anchor);
|
||||
|
||||
// the anchor carries an instant, not a wall-clock reading
|
||||
second.GetState().CurrentTime.ToUniversalTime().ShouldBe(new DateTimeOffset(anchor.NextStart, TimeSpan.Zero));
|
||||
|
||||
await second.AddCollection(ContentKey, CollectionName, PlaybackOrder.Chronological, CancellationToken.None);
|
||||
second.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeTrue();
|
||||
|
||||
PlayoutItem resumed = second.GetState().AddedItems.Single();
|
||||
resumed.Start.ShouldBe(anchor.NextStart);
|
||||
|
||||
// the guide group continues from the serialized context instead of restarting at 1
|
||||
resumed.GuideGroup.ShouldBe(lastGuideGroup + 1);
|
||||
}
|
||||
|
||||
private static SchedulingEngine NewEngine(IMediaCollectionRepository repository) =>
|
||||
new(
|
||||
repository,
|
||||
Substitute.For<IGraphicsElementRepository>(),
|
||||
Substitute.For<IChannelRepository>(),
|
||||
Substitute.For<ILogger<SchedulingEngine>>());
|
||||
|
||||
// WithReferenceData must precede RestoreOrReset and AddCollection: both dereference
|
||||
// PlayoutReferenceData.PlayoutHistory, so a different order fails with a null reference that reads
|
||||
// like an engine bug. This is the same order ScriptedPlayoutBuilder uses.
|
||||
private static SchedulingEngine ResetEngine(IMediaCollectionRepository repository)
|
||||
{
|
||||
SchedulingEngine engine = NewEngine(repository);
|
||||
engine.WithPlayoutId(1)
|
||||
.WithMode(PlayoutBuildMode.Reset)
|
||||
.WithSeed(0)
|
||||
.BuildBetween(Start, Start.AddDays(1))
|
||||
.WithReferenceData(EmptyReferenceData())
|
||||
.RestoreOrReset(Option<PlayoutAnchor>.None);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static async Task<SchedulingEngine> ResetEngineWithCollection()
|
||||
{
|
||||
SchedulingEngine engine = ResetEngine(CollectionRepository());
|
||||
await engine.AddCollection(ContentKey, CollectionName, PlaybackOrder.Chronological, CancellationToken.None);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static IMediaCollectionRepository CollectionRepository()
|
||||
{
|
||||
var repository = Substitute.For<IMediaCollectionRepository>();
|
||||
repository.GetCollectionItemsByName(CollectionName, Arg.Any<CancellationToken>())
|
||||
.Returns(_ => TestCollection());
|
||||
return repository;
|
||||
}
|
||||
|
||||
// Distinct release dates make chronological order deterministic (id order); distinct durations make
|
||||
// every boundary in an assertion unambiguous.
|
||||
private static List<MediaItem> TestCollection() =>
|
||||
[
|
||||
FakeMovie(1, 30),
|
||||
FakeMovie(2, 45),
|
||||
FakeMovie(3, 60),
|
||||
FakeMovie(4, 20),
|
||||
FakeMovie(5, 90),
|
||||
FakeMovie(6, 15)
|
||||
];
|
||||
|
||||
private static Movie FakeMovie(int id, int minutes) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(minutes) }],
|
||||
MovieMetadata =
|
||||
[
|
||||
new MovieMetadata
|
||||
{
|
||||
Title = $"Movie {id:D2}",
|
||||
ReleaseDate = new DateTime(2005, 1, 1).AddDays(id)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
private static PlayoutReferenceData EmptyReferenceData() =>
|
||||
new(null, Option<Deco>.None, [], [], null, [], [], TimeSpan.Zero);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,52 @@
|
||||
using System.IO;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Graphics;
|
||||
|
||||
public static class GraphicsElementDefaults
|
||||
{
|
||||
// Built-in "On Now / Next" text element; identity is by filename, never by user-editable Name.
|
||||
// Built-in "On Now / Next" text element filename -- a component of OnNowNextSeededPath below,
|
||||
// never itself an identity check (#568: a filename-only comparison is folder-agnostic).
|
||||
public const string OnNowNextFileName = "on-now-next.yml";
|
||||
|
||||
// Display name only. Never use it for identity -- that is the filename above (#67 / #74).
|
||||
// Display name only. Never use it for identity -- that is IsOnNowNext below (#67 / #74 / #568).
|
||||
public const string OnNowNextName = "On Now / Next";
|
||||
|
||||
// The full path the seeder writes the built-in template to (GraphicsElementSeeder.SeedOnNowNext
|
||||
// builds `target` the same way). A `builtIn` discriminator must match THIS, not
|
||||
// `Path.GetFileName(...) == OnNowNextFileName` -- a filename-only comparison is folder-agnostic:
|
||||
// a user element named exactly `on-now-next.yml` in any of the other four template folders
|
||||
// (image/motion/subtitle/script) would also report `builtIn:true` (#568). `Kind == Text` alone
|
||||
// does not close this either, since a second text template could share the filename in principle.
|
||||
public static string OnNowNextSeededPath =>
|
||||
Path.Combine(FileSystemLayout.GraphicsElementsTextTemplatesFolder, OnNowNextFileName);
|
||||
|
||||
/// <summary>
|
||||
/// The one identity test for the built-in On Now / Next element: the exact file the seeder
|
||||
/// wrote, at the exact path it wrote it to, of the kind it wrote it as. Ordinal on purpose,
|
||||
/// and so case-sensitive on purpose.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>Kind</c> is part of the identity rather than a second test any caller may add or
|
||||
/// skip: the seeder's own "does the built-in row exist yet?" check resolves through this
|
||||
/// predicate, so a row of another kind at the seeded path answering yes would suppress
|
||||
/// the Text row every consumer resolves. A caller applying only the path half would
|
||||
/// report that wrong-kind row as the built-in element while the seeder refused to treat
|
||||
/// it as one -- the two sites disagreeing about the same row, which is the defect this
|
||||
/// predicate exists to make impossible (#568).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Callers compare in memory rather than in a <c>Where</c> clause, because in SQL the
|
||||
/// answer would be the PROVIDER's to give: <c>GraphicsElement.Path</c> takes no explicit
|
||||
/// collation (<c>TvContext.OnModelCreating</c> pins one only on the listed name/title
|
||||
/// columns), so SQLite compares it case-sensitively while MySQL uses the server default,
|
||||
/// which is normally case-INsensitive. Evaluating one discriminator site in SQL and the
|
||||
/// other in memory would let the two disagree on MySQL alone. The SQLite test suite
|
||||
/// cannot tell the two apart -- BINARY collation and an ordinal comparison agree on
|
||||
/// every input -- so this is held by keeping the comparison out of SQL, not by a test.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static bool IsOnNowNext(string path, GraphicsElementKind kind) =>
|
||||
kind == GraphicsElementKind.Text && string.Equals(path, OnNowNextSeededPath, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
@@ -173,14 +173,25 @@ public static class GraphicsElementSeeder
|
||||
return;
|
||||
}
|
||||
|
||||
bool exists = await context.GraphicsElements.AnyAsync(e => e.Path == target, cancellationToken);
|
||||
if (exists)
|
||||
// "Does the built-in row already exist?" is the same question every consumer asks later, so
|
||||
// ask it with the same code instead of re-deriving it here. As its own SQL comparison
|
||||
// (`AnyAsync(e => e.Path == target)`) it could answer differently in two ways, and either
|
||||
// one leaves the built-in element undiscoverable after startup (#568):
|
||||
// * string equality in SQL is the PROVIDER's collation to decide, so on MySQL's normally
|
||||
// case-INsensitive default a case-variant row satisfied the check, the canonical row was
|
||||
// never created, and the ordinal lookup below then matched nothing;
|
||||
// * it ignored `Kind`, so a row of another kind sitting at the seeded path suppressed the
|
||||
// Text row the lookup actually resolves.
|
||||
// Creating the row stays idempotent because `target` IS the path the lookup matches -- held
|
||||
// by `Repeated_Seeding_Does_Not_Accumulate_Element_Rows`, which reddens if the two drift.
|
||||
if ((await GetBuiltInElementId(context, cancellationToken)).IsSome)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Name is display-only (identity is the filename), but leaving it null sorts the built-in
|
||||
// element into the unnamed bucket at the bottom of the SPA list until the first refresh.
|
||||
// Name is display-only (identity is the full seeded path, `target` above -- #568), but
|
||||
// leaving it null sorts the built-in element into the unnamed bucket at the bottom of the
|
||||
// SPA list until the first refresh.
|
||||
await context.GraphicsElements.AddAsync(
|
||||
new Core.Domain.GraphicsElement
|
||||
{
|
||||
@@ -246,29 +257,42 @@ public static class GraphicsElementSeeder
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identity is the filename, never the user-editable Name (the #67 lesson carried into #74).
|
||||
/// The Kind is part of it: the five template folders are separate namespaces, so an unrelated
|
||||
/// image/motion/subtitle/script element may legitimately be named `on-now-next.yml` too, and
|
||||
/// filename alone would hand back whichever row the unordered query happened to return first.
|
||||
/// Identity is the full seeded path, never the user-editable Name (the #67 lesson carried into
|
||||
/// #74) and never the bare filename (#568: filename-only matching is folder-agnostic, so a user
|
||||
/// element named exactly `on-now-next.yml` in a different template folder would also match).
|
||||
/// The <c>Kind</c> half of that identity is load-bearing rather than decorative:
|
||||
/// <c>EnsureBuiltInElementRow</c> asks this method whether the row it is about to create already
|
||||
/// exists, so a row of another kind at the seeded path must NOT answer yes -- it would suppress
|
||||
/// the Text row every consumer resolves.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both halves are <see cref="GraphicsElementDefaults.IsOnNowNext(string,GraphicsElementKind)"/>
|
||||
/// in memory rather than a <c>Where</c> clause. The path half must be, or the match would be the
|
||||
/// provider's collation to decide and this site would disagree with the API's `builtIn` (which
|
||||
/// compares in memory) on MySQL. The <c>Kind</c> half could be a SQL filter -- it is an enum,
|
||||
/// not a string -- but then this site would hold half the identity and the predicate the other
|
||||
/// half, and the API site could apply the predicate alone and quietly answer for rows this one
|
||||
/// rejects. That is exactly the disagreement #568 found, so identity is one predicate applied
|
||||
/// whole, at every site.
|
||||
/// </remarks>
|
||||
public static async Task<Option<int>> GetBuiltInElementId(
|
||||
TvContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<(int Id, string Path)> candidates = await context.GraphicsElements
|
||||
.Where(e => e.Kind == GraphicsElementKind.Text)
|
||||
.Select(e => new { e.Id, e.Path })
|
||||
List<(int Id, string Path, GraphicsElementKind Kind)> candidates = await context.GraphicsElements
|
||||
.Select(e => new { e.Id, e.Path, e.Kind })
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(rows => rows.Select(r => (r.Id, r.Path)).ToList());
|
||||
.Map(rows => rows.Select(r => (r.Id, r.Path, r.Kind)).ToList());
|
||||
|
||||
var matches = candidates
|
||||
.Where(c => System.IO.Path.GetFileName(c.Path) == GraphicsElementDefaults.OnNowNextFileName)
|
||||
.OrderBy(c => c.Id)
|
||||
List<int> matches = candidates
|
||||
.Where(c => GraphicsElementDefaults.IsOnNowNext(c.Path, c.Kind))
|
||||
.Select(c => c.Id)
|
||||
.OrderBy(id => id)
|
||||
.ToList();
|
||||
|
||||
// Lowest id wins if two text templates somehow share the filename, so the choice is stable
|
||||
// across restarts rather than dependent on query order.
|
||||
return matches.Count == 0 ? Option<int>.None : matches[0].Id;
|
||||
// Lowest id wins if two rows somehow share the seeded path, so the choice is stable across
|
||||
// restarts rather than dependent on query order.
|
||||
return matches.Count == 0 ? Option<int>.None : matches[0];
|
||||
}
|
||||
|
||||
private static async Task UpgradeUnmodifiedTemplate(
|
||||
|
||||
@@ -26,7 +26,7 @@ public class CreateChannelDefaultGraphicsElementTests : ChannelHandlerTestBase
|
||||
await using TvContext context = Db.CreateContext();
|
||||
var element = new GraphicsElement
|
||||
{
|
||||
Path = $"/templates/text/{GraphicsElementDefaults.OnNowNextFileName}",
|
||||
Path = GraphicsElementDefaults.OnNowNextSeededPath,
|
||||
Kind = GraphicsElementKind.Text
|
||||
};
|
||||
|
||||
|
||||
@@ -464,7 +464,7 @@ public class CreateChannelFromLineupHandlerTests
|
||||
[Test]
|
||||
public async Task Should_Reject_WeightedShuffle_For_A_Multi_Item_Lineup()
|
||||
{
|
||||
// regression (#70, found by adversarial review of PR #402): a 2+ entry lineup is persisted as a
|
||||
// regression (#70, PR #402): a 2+ entry lineup is persisted as a
|
||||
// Playlist, and PlaylistEnumerator has no default arm -- an order it doesn't know leaves the
|
||||
// enumerator null and the items vanish from the playlist with nothing reported. This handler is the
|
||||
// THIRD writer of PlaylistItem.PlaybackOrder and was missed when the other two were gated.
|
||||
@@ -1020,7 +1020,7 @@ public class CreateChannelFromLineupHandlerTests
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var element = new GraphicsElement
|
||||
{
|
||||
Path = $"/templates/text/{GraphicsElementDefaults.OnNowNextFileName}",
|
||||
Path = GraphicsElementDefaults.OnNowNextSeededPath,
|
||||
Kind = GraphicsElementKind.Text
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
@@ -15,6 +18,9 @@ public class UpdateChannelGraphicsElementsTests : ChannelHandlerTestBase
|
||||
{
|
||||
private UpdateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets, RemoteLogoCacher);
|
||||
|
||||
private static BaseError LeftOf(Either<BaseError, ChannelViewModel> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
|
||||
private async Task<(int ElementAId, int ElementBId)> SeedGraphicsElements()
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
@@ -25,6 +31,37 @@ public class UpdateChannelGraphicsElementsTests : ChannelHandlerTestBase
|
||||
return (elementA.Id, elementB.Id);
|
||||
}
|
||||
|
||||
private async Task<int> SeedWatermark()
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
var watermark = new ChannelWatermark { Name = "W" };
|
||||
context.ChannelWatermarks.Add(watermark);
|
||||
await context.SaveChangesAsync();
|
||||
return watermark.Id;
|
||||
}
|
||||
|
||||
private async Task<List<int>> SeedGraphicsElements(int count)
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
List<GraphicsElement> elements = Enumerable.Range(0, count)
|
||||
.Select(i => new GraphicsElement { Path = $"element-{i}.yml" })
|
||||
.ToList();
|
||||
context.GraphicsElements.AddRange(elements);
|
||||
await context.SaveChangesAsync();
|
||||
return elements.Select(e => e.Id).ToList();
|
||||
}
|
||||
|
||||
// Replaces the harness the base class built with one whose SaveChangesAsync can be made to fail
|
||||
// on demand. Foreign keys are off in InMemoryTvContext, so the FK violation a concurrent delete
|
||||
// really produces cannot be raised by seeding alone.
|
||||
private async Task<ArmedSaveFailureInterceptor> UseFailingSaveHarness()
|
||||
{
|
||||
await Db.DisposeAsync();
|
||||
var interceptor = new ArmedSaveFailureInterceptor();
|
||||
Db = await InMemoryTvContext.CreateAsync(interceptor);
|
||||
return interceptor;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reconcile_GraphicsElement_Join_Add_Then_Remove()
|
||||
{
|
||||
@@ -73,4 +110,199 @@ public class UpdateChannelGraphicsElementsTests : ChannelHandlerTestBase
|
||||
reloaded.ChannelGraphicsElements.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// #568: an unknown graphicsElementIds entry used to reach ApplyUpdateRequest unchecked, which
|
||||
// blindly Adds a ChannelGraphicsElement and lets SaveChangesAsync hit
|
||||
// FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId -> unhandled 500. Removing the
|
||||
// GraphicsElementIdsMustExist validator alone from UpdateChannelHandler.Validate is row 35 of the
|
||||
// mutation table in docs/graphics-elements.md, measured against the whole ErsatzTV.Tests project.
|
||||
[Test]
|
||||
public async Task Should_Reject_Unknown_GraphicsElementId_With_422_Not_500()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
Channel channel = await SeedChannel(1, "5");
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [999]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("[GraphicsElementIds]");
|
||||
error.Value.ShouldContain("999");
|
||||
|
||||
// no partial write: the channel keeps no graphics element association
|
||||
await using TvContext context = Db.CreateContext();
|
||||
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
|
||||
.SingleAsync(c => c.Id == channel.Id);
|
||||
reloaded.ChannelGraphicsElements.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_When_One_Of_Several_GraphicsElementIds_Is_Unknown()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
Channel channel = await SeedChannel(1, "5");
|
||||
(int elementAId, _) = await SeedGraphicsElements();
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId, 12345]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("[GraphicsElementIds]");
|
||||
error.Value.ShouldContain("12345");
|
||||
}
|
||||
|
||||
// #568: the id list is client-supplied and was bounded only by the Kestrel body cap, which is a
|
||||
// transport limit and not a collection limit. The cap is Validators.MaximumIdListCount, shared
|
||||
// by all three id-list validators; these three tests pin its two edges and the shape that makes
|
||||
// its placement matter. Removing the cap from Validators.IdsMustExist is row 41 of the mutation
|
||||
// table in docs/graphics-elements.md.
|
||||
[Test]
|
||||
public async Task Should_Accept_Exactly_The_Maximum_Number_Of_GraphicsElementIds()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
Channel channel = await SeedChannel(1, "5");
|
||||
List<int> ids = await SeedGraphicsElements(Validators.MaximumIdListCount);
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
|
||||
.SingleAsync(c => c.Id == channel.Id);
|
||||
reloaded.ChannelGraphicsElements.Count.ShouldBe(Validators.MaximumIdListCount);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
Channel channel = await SeedChannel(1, "5");
|
||||
List<int> ids = Enumerable.Range(1, Validators.MaximumIdListCount + 1).ToList();
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.Value.ShouldContain("[GraphicsElementIds]");
|
||||
error.Value.ShouldContain((Validators.MaximumIdListCount + 1).ToString(CultureInfo.InvariantCulture));
|
||||
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
// The cap counts the RAW list, before Distinct: every one of these ids exists and they collapse
|
||||
// to a single distinct id, so a cap applied after deduplication would accept this request and
|
||||
// leave the parse and materialization it costs unbounded.
|
||||
[Test]
|
||||
public async Task Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
Channel channel = await SeedChannel(1, "5");
|
||||
(int elementAId, _) = await SeedGraphicsElements();
|
||||
List<int> ids = Enumerable.Repeat(elementAId, Validators.MaximumIdListCount + 1).ToList();
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.Value.ShouldContain("[GraphicsElementIds]");
|
||||
error.Value.ShouldContain((Validators.MaximumIdListCount + 1).ToString(CultureInfo.InvariantCulture));
|
||||
error.Value.ShouldNotContain("do not exist");
|
||||
}
|
||||
|
||||
// A 422 that echoes every rejected id turns an oversized request into an oversized response.
|
||||
// Removing the truncation from Validators.DescribeIds is row 42 of the mutation table.
|
||||
[Test]
|
||||
public async Task Should_Cap_The_Ids_Echoed_Back_In_The_Unknown_Id_422()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
Channel channel = await SeedChannel(1, "5");
|
||||
List<int> ids = Enumerable.Range(1001, 30).ToList();
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.Value.ShouldContain("1001");
|
||||
error.Value.ShouldContain("(and 20 more)");
|
||||
error.Value.ShouldNotContain("1030");
|
||||
}
|
||||
|
||||
// #568: validation and the write are two statements, so RefreshGraphicsElements can delete a
|
||||
// validated element in between and hand the join insert the FK violation the validator exists to
|
||||
// prevent -- the unhandled 500 again. Removing the DbUpdateException catch from
|
||||
// ApplyUpdateRequestTranslatingLostRace is row 43 of the mutation table.
|
||||
[Test]
|
||||
public async Task Should_Translate_An_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422()
|
||||
{
|
||||
ArmedSaveFailureInterceptor interceptor = await UseFailingSaveHarness();
|
||||
await SeedFFmpegProfile();
|
||||
Channel channel = await SeedChannel(1, "5");
|
||||
(int elementAId, _) = await SeedGraphicsElements();
|
||||
|
||||
interceptor.SqlBeforeFailing =
|
||||
$"DELETE FROM GraphicsElement WHERE Id = {elementAId.ToString(CultureInfo.InvariantCulture)}";
|
||||
interceptor.Armed = true;
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.Value.ShouldContain("[GraphicsElementIds]");
|
||||
error.Value.ShouldContain(elementAId.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
// graphicsElementIds is not the only FK this DTO writes, and the recovery path re-asks the whole
|
||||
// of Validate rather than the graphics-element half precisely so the other FKs are covered:
|
||||
// WatermarkId is written by the same SaveChangesAsync and loses the same race. Reddens if the
|
||||
// recheck is narrowed back to GraphicsElementIdsMustExist -- row 45 of the mutation table in
|
||||
// docs/graphics-elements.md.
|
||||
[Test]
|
||||
public async Task Should_Translate_A_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422()
|
||||
{
|
||||
ArmedSaveFailureInterceptor interceptor = await UseFailingSaveHarness();
|
||||
await SeedFFmpegProfile();
|
||||
Channel channel = await SeedChannel(1, "5");
|
||||
int watermarkId = await SeedWatermark();
|
||||
|
||||
interceptor.SqlBeforeFailing =
|
||||
$"DELETE FROM ChannelWatermark WHERE Id = {watermarkId.ToString(CultureInfo.InvariantCulture)}";
|
||||
interceptor.Armed = true;
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", watermarkId: watermarkId),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.Value.ShouldContain("Watermark");
|
||||
error.Value.ShouldContain(watermarkId.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
// The other half of that catch: a DbUpdateException whose cause is NOT a missing graphics
|
||||
// element is a real fault and must keep its own exception rather than be reported to the client
|
||||
// as a validation error about ids that are all still present.
|
||||
[Test]
|
||||
public async Task Should_Not_Report_An_Unrelated_DbUpdateException_As_A_Graphics_Element_422()
|
||||
{
|
||||
ArmedSaveFailureInterceptor interceptor = await UseFailingSaveHarness();
|
||||
await SeedFFmpegProfile();
|
||||
Channel channel = await SeedChannel(1, "5");
|
||||
(int elementAId, _) = await SeedGraphicsElements();
|
||||
|
||||
interceptor.Armed = true;
|
||||
|
||||
await Should.ThrowAsync<DbUpdateException>(
|
||||
async () => await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId]),
|
||||
CancellationToken.None));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using ErsatzTV.Application.Graphics;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Graphics;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NUnit.Framework;
|
||||
@@ -62,6 +64,97 @@ public class GraphicsElementHandlerTests
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// #568: the discriminator used to be Path.GetFileName(e.Path) == OnNowNextFileName, which is
|
||||
// folder-agnostic -- a user element named exactly "on-now-next.yml" outside the seeded text
|
||||
// template folder would also report builtIn:true. The row carries the SAME Kind as the real
|
||||
// seeded element, deliberately: a wrong-kind row here would be rejected by the Kind conjunct
|
||||
// whatever the path comparison is, so the composite revert (filename AND Kind == Text) would
|
||||
// pass. Only the PATH half can reject a Text row in another folder, which is what this pins --
|
||||
// it mirrors the seeder-site Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder.
|
||||
[Test]
|
||||
public async Task GetAllGraphicsElementsForApi_Should_Not_Mark_Same_Filename_Outside_Seeded_Folder_As_BuiltIn()
|
||||
{
|
||||
string userElementPath = System.IO.Path.Combine(
|
||||
"/config/graphics-elements/text/some-subfolder",
|
||||
GraphicsElementDefaults.OnNowNextFileName);
|
||||
await SeedElement(1, userElementPath, GraphicsElementKind.Text, string.Empty);
|
||||
|
||||
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
|
||||
|
||||
List<GraphicsElementResponseModel> result =
|
||||
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].BuiltIn.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAllGraphicsElementsForApi_Should_Mark_The_Seeded_Path_As_BuiltIn()
|
||||
{
|
||||
await SeedElement(
|
||||
1,
|
||||
GraphicsElementDefaults.OnNowNextSeededPath,
|
||||
GraphicsElementKind.Text,
|
||||
GraphicsElementDefaults.OnNowNextName);
|
||||
|
||||
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
|
||||
|
||||
List<GraphicsElementResponseModel> result =
|
||||
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].BuiltIn.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// #568: identity is GraphicsElementDefaults.IsOnNowNext, an ORDINAL comparison, so a row whose
|
||||
// path differs from the seeded one only in case is a different element. Reddens if that
|
||||
// comparison is loosened to OrdinalIgnoreCase. It does NOT pin provider independence: under
|
||||
// SQLite's BINARY collation a `Where(e => e.Path == ...)` in SQL answers identically, which is
|
||||
// why the comparison is kept in memory rather than pinned here (see IsOnNowNext's remarks).
|
||||
[Test]
|
||||
public async Task GetAllGraphicsElementsForApi_Should_Not_Mark_A_Case_Variant_Of_The_Seeded_Path_As_BuiltIn()
|
||||
{
|
||||
await SeedElement(1, CaseVariantOfSeededPath(), GraphicsElementKind.Text, string.Empty);
|
||||
|
||||
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
|
||||
|
||||
List<GraphicsElementResponseModel> result =
|
||||
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].BuiltIn.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// #568: Kind is part of the built-in element's identity, not a second test the seeder applies
|
||||
// and the API skips. GetBuiltInElementId refuses a wrong-kind row at the seeded path -- it has
|
||||
// to, since EnsureBuiltInElementRow asks it whether the Text row it is about to create already
|
||||
// exists -- so an API that reported the same row as builtIn:true would have the two sites
|
||||
// disagreeing about one row. Reddens if the Kind conjunct is dropped from
|
||||
// GraphicsElementDefaults.IsOnNowNext (row 18 of the mutation table in docs/graphics-elements.md).
|
||||
[Test]
|
||||
public async Task GetAllGraphicsElementsForApi_Should_Not_Mark_A_Wrong_Kind_Row_At_The_Seeded_Path_As_BuiltIn()
|
||||
{
|
||||
await SeedElement(
|
||||
1,
|
||||
GraphicsElementDefaults.OnNowNextSeededPath,
|
||||
GraphicsElementKind.Image,
|
||||
string.Empty);
|
||||
|
||||
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
|
||||
|
||||
List<GraphicsElementResponseModel> result =
|
||||
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].BuiltIn.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// The seeded path with only the FILENAME's case changed -- same folder, same spelling.
|
||||
private static string CaseVariantOfSeededPath() =>
|
||||
System.IO.Path.Combine(
|
||||
System.IO.Path.GetDirectoryName(GraphicsElementDefaults.OnNowNextSeededPath)!,
|
||||
GraphicsElementDefaults.OnNowNextFileName.ToUpperInvariant());
|
||||
|
||||
private async Task SeedElement(int id, string path, GraphicsElementKind kind, string name)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
@@ -358,9 +358,9 @@ public class ScheduleItemResponseRoundTripTests
|
||||
// that for this fixture's payload: it forwards every Id, takes the id-based reconcile, and
|
||||
// updates rows in place. So Id compares equal, and the exemption was unnecessary.
|
||||
//
|
||||
// Two mutations of this fixture, both EXECUTED — recorded as results, with no account of why,
|
||||
// because three earlier drafts of this comment each supplied a confident mechanism for a
|
||||
// correct observation and two of them were contradicted by the code:
|
||||
// Two mutations of this fixture, both EXECUTED — recorded as results, with no account of why:
|
||||
// a confident mechanism for this observation is easy to get wrong, and two independent ones
|
||||
// were each contradicted by the code:
|
||||
//
|
||||
// ToReplaceCommand passes `null` for EVERY id -> test stays GREEN
|
||||
// ToReplaceCommand passes `null` for index 0 only -> test goes RED, "Id differs"
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// #568: the same full-replace-DTO FK hardening applied to UpdateChannelHandler's
|
||||
/// graphicsElementIds also closes the identical twin defect in UpdateDecoHandler -- both
|
||||
/// graphicsElementIds and watermarkIds are top-level ReplaceDecoRequest fields (not the "deep FK
|
||||
/// ids nested inside item-list request bodies" carve-out in api-conventions.md), and the
|
||||
/// reconcile in ApplyUpdateRequest blindly Adds a join row for every incoming id, so an unknown
|
||||
/// id used to hit the FK constraint at SaveChangesAsync and surface as an unhandled 500.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class UpdateDecoGraphicsElementsTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private ChannelWriter<IBackgroundServiceRequest> _channel = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_channel = Substitute.For<ChannelWriter<IBackgroundServiceRequest>>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private static bool IsLeft<T>(Either<BaseError, T> result) => result.Match(Right: _ => false, Left: _ => true);
|
||||
|
||||
private async Task SeedDeco()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.Decos.Add(
|
||||
new Deco
|
||||
{
|
||||
Id = 1,
|
||||
DecoGroupId = 1,
|
||||
Name = "D",
|
||||
BreakContent = [],
|
||||
DecoWatermarks = [],
|
||||
DecoGraphicsElements = []
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static UpdateDeco MakeUpdate(
|
||||
List<int> graphicsElementIds = null,
|
||||
List<int> watermarkIds = null,
|
||||
DecoMode? graphicsElementsMode = null,
|
||||
DecoMode? watermarkMode = null) =>
|
||||
new(
|
||||
1,
|
||||
1,
|
||||
"D",
|
||||
watermarkMode ?? DecoMode.Inherit,
|
||||
watermarkIds ?? [],
|
||||
false,
|
||||
graphicsElementsMode ?? (graphicsElementIds is null ? DecoMode.Inherit : DecoMode.Override),
|
||||
graphicsElementIds ?? [],
|
||||
false,
|
||||
DecoMode.Inherit,
|
||||
[],
|
||||
DecoMode.Inherit,
|
||||
CollectionType.Collection,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
DecoMode.Inherit,
|
||||
CollectionType.Collection,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
private async Task<int> SeedGraphicsElement()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var element = new GraphicsElement { Path = "element-a.yml" };
|
||||
context.GraphicsElements.Add(element);
|
||||
await context.SaveChangesAsync();
|
||||
return element.Id;
|
||||
}
|
||||
|
||||
private async Task<List<int>> SeedGraphicsElements(int count)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
List<GraphicsElement> elements = Enumerable.Range(0, count)
|
||||
.Select(i => new GraphicsElement { Path = $"element-{i}.yml" })
|
||||
.ToList();
|
||||
context.GraphicsElements.AddRange(elements);
|
||||
await context.SaveChangesAsync();
|
||||
return elements.Select(e => e.Id).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<int>> SeedWatermarks(int count)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
List<ChannelWatermark> watermarks = Enumerable.Range(0, count)
|
||||
.Select(i => new ChannelWatermark { Name = $"W{i}" })
|
||||
.ToList();
|
||||
context.ChannelWatermarks.AddRange(watermarks);
|
||||
await context.SaveChangesAsync();
|
||||
return watermarks.Select(w => w.Id).ToList();
|
||||
}
|
||||
|
||||
private async Task<int> SeedWatermark()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var watermark = new ChannelWatermark { Name = "W" };
|
||||
context.ChannelWatermarks.Add(watermark);
|
||||
await context.SaveChangesAsync();
|
||||
return watermark.Id;
|
||||
}
|
||||
|
||||
private async Task AttachWatermark(int watermarkId)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Deco deco = await context.Decos.Include(d => d.DecoWatermarks).SingleAsync(d => d.Id == 1);
|
||||
deco.WatermarkMode = DecoMode.Override;
|
||||
deco.DecoWatermarks.Add(new DecoWatermark { DecoId = 1, WatermarkId = watermarkId });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task AttachGraphicsElement(int elementId)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Deco deco = await context.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
|
||||
deco.GraphicsElementsMode = DecoMode.Override;
|
||||
deco.DecoGraphicsElements.Add(new DecoGraphicsElement { DecoId = 1, GraphicsElementId = elementId });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Removing UpdateDecoHandler.GraphicsElementIdsMustExist alone from Validate is row 36 of the
|
||||
// mutation table in docs/graphics-elements.md, measured against the whole ErsatzTV.Tests project.
|
||||
[Test]
|
||||
public async Task Should_Reject_Unknown_GraphicsElementId_With_A_Validation_Error_Not_A_Throw()
|
||||
{
|
||||
await SeedDeco();
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(graphicsElementIds: [999]),
|
||||
CancellationToken.None);
|
||||
|
||||
IsLeft(result).ShouldBeTrue();
|
||||
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
|
||||
error.Value.ShouldContain("[GraphicsElementIds]");
|
||||
error.Value.ShouldContain("999");
|
||||
|
||||
// no partial write: the deco keeps no graphics element association
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Deco reloaded = await context.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
|
||||
reloaded.DecoGraphicsElements.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// Removing UpdateDecoHandler.WatermarkIdsMustExist alone from Validate is row 37 of the
|
||||
// mutation table in docs/graphics-elements.md.
|
||||
[Test]
|
||||
public async Task Should_Reject_Unknown_WatermarkId_With_A_Validation_Error_Not_A_Throw()
|
||||
{
|
||||
await SeedDeco();
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new UpdateDeco(
|
||||
1,
|
||||
1,
|
||||
"D",
|
||||
DecoMode.Override,
|
||||
[999],
|
||||
false,
|
||||
DecoMode.Inherit,
|
||||
[],
|
||||
false,
|
||||
DecoMode.Inherit,
|
||||
[],
|
||||
DecoMode.Inherit,
|
||||
CollectionType.Collection,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
DecoMode.Inherit,
|
||||
CollectionType.Collection,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null),
|
||||
CancellationToken.None);
|
||||
|
||||
IsLeft(result).ShouldBeTrue();
|
||||
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
|
||||
error.Value.ShouldContain("[WatermarkIds]");
|
||||
error.Value.ShouldContain("999");
|
||||
}
|
||||
|
||||
// The mode, not the id list, decides whether an id is data. ApplyUpdateRequest reconciles the
|
||||
// join table only under Override/Merge and Clear()s it otherwise, so validating unconditionally
|
||||
// would reject a save the apply path was going to discard. Removing the ConsumesGraphicsElementIds
|
||||
// guard alone from UpdateDecoHandler.GraphicsElementIdsMustExist is row 38 of the mutation table
|
||||
// in docs/graphics-elements.md.
|
||||
[Test]
|
||||
public async Task Should_Ignore_An_Unknown_GraphicsElementId_When_The_Mode_Does_Not_Consume_It()
|
||||
{
|
||||
await SeedDeco();
|
||||
int elementId = await SeedGraphicsElement();
|
||||
await AttachGraphicsElement(elementId);
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(graphicsElementsMode: DecoMode.Inherit, graphicsElementIds: [999]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
// the apply path discards the ids under Inherit, and the existing attachment with them
|
||||
await using TvContext reload = _db.CreateContext();
|
||||
Deco reloaded = await reload.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
|
||||
reloaded.GraphicsElementsMode.ShouldBe(DecoMode.Inherit);
|
||||
reloaded.DecoGraphicsElements.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// Twin of the above for the watermark half; removing the ConsumesWatermarkIds guard alone from
|
||||
// UpdateDecoHandler.WatermarkIdsMustExist is row 39 of the mutation table in
|
||||
// docs/graphics-elements.md.
|
||||
[Test]
|
||||
public async Task Should_Ignore_An_Unknown_WatermarkId_When_The_Mode_Does_Not_Consume_It()
|
||||
{
|
||||
await SeedDeco();
|
||||
int watermarkId = await SeedWatermark();
|
||||
await AttachWatermark(watermarkId);
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(watermarkMode: DecoMode.Disable, watermarkIds: [999]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext reload = _db.CreateContext();
|
||||
Deco reloaded = await reload.Decos.Include(d => d.DecoWatermarks).SingleAsync(d => d.Id == 1);
|
||||
reloaded.WatermarkMode.ShouldBe(DecoMode.Disable);
|
||||
reloaded.DecoWatermarks.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Accept_A_Known_GraphicsElementId()
|
||||
{
|
||||
await SeedDeco();
|
||||
|
||||
int elementId = await SeedGraphicsElement();
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(graphicsElementIds: [elementId]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext reload = _db.CreateContext();
|
||||
Deco reloaded = await reload.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
|
||||
reloaded.DecoGraphicsElements.Select(x => x.GraphicsElementId).ShouldBe(new[] { elementId });
|
||||
}
|
||||
|
||||
// Both deco id lists go through the same Validators.IdsMustExist as the channel's, so both
|
||||
// inherit the same raw-count cap; the channel fixture pins its edges, these two pin that each
|
||||
// deco field is actually behind it and names itself when it rejects.
|
||||
[Test]
|
||||
public async Task Should_Reject_More_Than_The_Maximum_Number_Of_GraphicsElementIds()
|
||||
{
|
||||
await SeedDeco();
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(graphicsElementIds: Enumerable.Range(1, Validators.MaximumIdListCount + 1).ToList()),
|
||||
CancellationToken.None);
|
||||
|
||||
IsLeft(result).ShouldBeTrue();
|
||||
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
|
||||
error.Value.ShouldContain("[GraphicsElementIds]");
|
||||
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_More_Than_The_Maximum_Number_Of_WatermarkIds()
|
||||
{
|
||||
await SeedDeco();
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(
|
||||
watermarkMode: DecoMode.Override,
|
||||
watermarkIds: Enumerable.Range(1, Validators.MaximumIdListCount + 1).ToList()),
|
||||
CancellationToken.None);
|
||||
|
||||
IsLeft(result).ShouldBeTrue();
|
||||
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
|
||||
error.Value.ShouldContain("[WatermarkIds]");
|
||||
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
// The mode gate is handed to Validators.IdsMustExist rather than short-circuiting the call,
|
||||
// because only the EXISTENCE half is the apply path's business: a list the reconcile discards
|
||||
// was still parsed and materialized out of the request body. These two pin that the cap holds
|
||||
// under a mode that consumes nothing -- row 47 of the mutation table in
|
||||
// docs/graphics-elements.md. Note the ids all EXIST here, so nothing but the cap can reject
|
||||
// them: a rejection is the cap's, not a smuggled existence check.
|
||||
[Test]
|
||||
public async Task Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them()
|
||||
{
|
||||
await SeedDeco();
|
||||
List<int> ids = await SeedGraphicsElements(Validators.MaximumIdListCount + 1);
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(graphicsElementsMode: DecoMode.Inherit, graphicsElementIds: ids),
|
||||
CancellationToken.None);
|
||||
|
||||
IsLeft(result).ShouldBeTrue();
|
||||
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
|
||||
error.Value.ShouldContain("[GraphicsElementIds]");
|
||||
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them()
|
||||
{
|
||||
await SeedDeco();
|
||||
List<int> ids = await SeedWatermarks(Validators.MaximumIdListCount + 1);
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(watermarkMode: DecoMode.Disable, watermarkIds: ids),
|
||||
CancellationToken.None);
|
||||
|
||||
IsLeft(result).ShouldBeTrue();
|
||||
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
|
||||
error.Value.ShouldContain("[WatermarkIds]");
|
||||
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
// The deco twin of the channel handler's lost-race translation: an element deleted between
|
||||
// Validate and SaveChangesAsync must come back as the validator's own 422, not the FK
|
||||
// exception. Removing the DbUpdateException catch from
|
||||
// UpdateDecoHandler.ApplyUpdateRequestTranslatingLostRace is row 44 of the mutation table in
|
||||
// docs/graphics-elements.md.
|
||||
[Test]
|
||||
public async Task Should_Translate_A_Deco_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422()
|
||||
{
|
||||
var interceptor = new ArmedSaveFailureInterceptor();
|
||||
await _db.DisposeAsync();
|
||||
_db = await InMemoryTvContext.CreateAsync(interceptor);
|
||||
|
||||
await SeedDeco();
|
||||
int elementId = await SeedGraphicsElement();
|
||||
|
||||
interceptor.SqlBeforeFailing =
|
||||
$"DELETE FROM GraphicsElement WHERE Id = {elementId.ToString(CultureInfo.InvariantCulture)}";
|
||||
interceptor.Armed = true;
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(graphicsElementIds: [elementId]),
|
||||
CancellationToken.None);
|
||||
|
||||
IsLeft(result).ShouldBeTrue();
|
||||
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
|
||||
error.Value.ShouldContain("[GraphicsElementIds]");
|
||||
error.Value.ShouldContain(elementId.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
// The watermark half of the same recovery. Without it, removing the watermark question from the
|
||||
// recheck would redden nothing -- and the recheck re-asks the whole of Validate exactly so that
|
||||
// neither id list is the only one covered. Row 46 of the mutation table.
|
||||
[Test]
|
||||
public async Task Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422()
|
||||
{
|
||||
var interceptor = new ArmedSaveFailureInterceptor();
|
||||
await _db.DisposeAsync();
|
||||
_db = await InMemoryTvContext.CreateAsync(interceptor);
|
||||
|
||||
await SeedDeco();
|
||||
int watermarkId = await SeedWatermark();
|
||||
|
||||
interceptor.SqlBeforeFailing =
|
||||
$"DELETE FROM ChannelWatermark WHERE Id = {watermarkId.ToString(CultureInfo.InvariantCulture)}";
|
||||
interceptor.Armed = true;
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(watermarkMode: DecoMode.Override, watermarkIds: [watermarkId]),
|
||||
CancellationToken.None);
|
||||
|
||||
IsLeft(result).ShouldBeTrue();
|
||||
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
|
||||
error.Value.ShouldContain("[WatermarkIds]");
|
||||
error.Value.ShouldContain(watermarkId.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
@@ -372,7 +372,7 @@ public class GetSearchFieldValuesHandlerTests
|
||||
[Test]
|
||||
public async Task List_Valued_Walk_Reads_Live_Rows_Regardless_Of_Id_Density()
|
||||
{
|
||||
// THE round-4 killer. That revision bounded the Id KEYSPACE, and keyspace is not rows: with 20,000
|
||||
// Bounding the Id KEYSPACE is the killer here: keyspace is not rows — with 20,000
|
||||
// historical rows deleted and one live song at Id 20001, the walk spent its whole allowance on empty
|
||||
// ranges and returned [] for a table containing exactly one row. Capacity degraded linearly with
|
||||
// deletion ratio, and no ratio was safe -- one placed gap hid the next match.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"description": "Committed stand-in for a user-authored scripted schedule: the ordered sequence of calls such a script makes against /api/v1/scripted/playout/build/{buildId}, replayed through the real ScriptedScheduleController by ScriptedScheduleControllerTests. Field names and casing match what the HTTP body binder accepts \u2014 the replay deserializes each body with ApiJsonSettings, the configuration Startup applies to AddNewtonsoftJson. Deliberately avoids wait_until / pad_until / pad_to_next (local day and time-of-day) and shuffle order, which would make the pinned snapshot machine-timezone- or seed-dependent. add_duration and pad_until_exact both end BETWEEN two content boundaries on purpose: an instruction that happens to end on one never reaches the engine's trim branch, and its trim flag is then witnessed by nothing.",
|
||||
"script": [
|
||||
{
|
||||
"action": "add_collection",
|
||||
"body": {
|
||||
"key": "content",
|
||||
"collection": "Test Collection",
|
||||
"order": "chronological"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "start_epg_group",
|
||||
"body": {
|
||||
"advance": true,
|
||||
"customTitle": "Morning Block"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "add_count",
|
||||
"body": {
|
||||
"content": "content",
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "stop_epg_group"
|
||||
},
|
||||
{
|
||||
"action": "add_duration",
|
||||
"body": {
|
||||
"content": "content",
|
||||
"duration": "2:00:00",
|
||||
"trim": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "pad_until_exact",
|
||||
"body": {
|
||||
"content": "content",
|
||||
"when": "2026-01-15T09:55:00+00:00",
|
||||
"trim": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "add_all",
|
||||
"body": {
|
||||
"content": "content"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -24,14 +24,13 @@ namespace ErsatzTV.Tests.Controllers;
|
||||
[TestFixture]
|
||||
public class OpenApiSerializerContractTests
|
||||
{
|
||||
// Mirrors Startup.ConfigureServices -> AddNewtonsoftJson exactly.
|
||||
private static readonly JsonSerializerSettings RuntimeSettings = new()
|
||||
{
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
|
||||
ContractResolver = new CustomContractResolver(),
|
||||
Converters = { new StringEnumConverter() }
|
||||
};
|
||||
// The configuration Startup.ConfigureServices -> AddNewtonsoftJson applies, from the same function
|
||||
// rather than a hand-copy of it. This fixture is the WRITE-side witness for the naming strategy: the
|
||||
// four cases below assert camelCase keys, which CustomContractResolver produces and a bare
|
||||
// JsonSerializerSettings does not. It says nothing about NullValueHandling (every DTO member below is
|
||||
// populated, so nothing is dropped either way) or the StringEnumConverter (it compares key NAMES, not
|
||||
// values). docs/testing.md -> "Scripted playout coverage" tabulates which suite witnesses which half.
|
||||
private static readonly JsonSerializerSettings RuntimeSettings = ApiJsonSettings.Create();
|
||||
|
||||
private static IEnumerable<TestCaseData> Cases()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Core.Api.ScriptedPlayout;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling.Engine;
|
||||
using ErsatzTV.Core.Scheduling.ScriptedScheduling;
|
||||
using ErsatzTV.Serialization;
|
||||
using LanguageExt;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// In-process stand-in for a scripted playout build (ersatztv#563). A committed script fixture
|
||||
/// (<c>Fixtures/scripted-build.json</c>) is replayed through the REAL
|
||||
/// <see cref="ScriptedScheduleController" />, the REAL <see cref="ScriptedPlayoutBuilderService" /> and the
|
||||
/// REAL <see cref="SchedulingEngine" />, and the resulting <c>PlayoutItem</c>s are compared to a pinned
|
||||
/// snapshot in the same line format the playout goldens use. The engine is registered under a fixed build
|
||||
/// id via <see cref="ScriptedPlayoutBuilderService.MockSession" />, the seam that exists for exactly this.
|
||||
/// <para>
|
||||
/// The hops this cannot host, named rather than implied: the <c>Cli.Wrap</c> launch of the user's
|
||||
/// own program (exit code, timeout, stdout capture); the Kestrel/middleware/auth transport the
|
||||
/// program calls back over; and MVC's binding <i>wrapper</i> — the input formatter's content-type
|
||||
/// selection and malformed-body handling, model validation (a non-nullable reference type picks
|
||||
/// up an implicit required check there), and the <c>[ApiController]</c> automatic 400 either
|
||||
/// produces before an action runs, since every test here hands an action an already-bound object.
|
||||
/// The serializer <i>inside</i> that wrapper is not residue: request bodies are deserialized with
|
||||
/// <see cref="ApiJsonSettings" />, the same function <c>Startup</c> hands to <c>AddNewtonsoftJson</c>.
|
||||
/// It is the production <i>configuration</i>, not the production <i>object</i> — MVC applies it to
|
||||
/// settings it has already configured, and <c>ApiJsonSettings.Create()</c> starts from a bare one, so
|
||||
/// MVC's stricter <c>MaxDepth</c> and its two <c>ProblemDetails</c> converters are missing here
|
||||
/// (enumerated and pinned by <c>ApiJsonSettingsTests</c>; both inert for these DTOs, which nest two
|
||||
/// levels and are never a <c>ProblemDetails</c>). Two tests hold the two ways that binder can be
|
||||
/// replaced, since the fixture's own bodies parse identically under all of them:
|
||||
/// <see cref="Production_Body_Binder_Ignores_Required_Members" /> against a System.Text.Json swap, and
|
||||
/// <see cref="Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null" /> against a
|
||||
/// plain Newtonsoft settings object. What no test here observes is <c>Startup</c>'s own
|
||||
/// registration — <see cref="ApiJsonSettings" /> removes the duplicate rather than detecting
|
||||
/// drift in one. The reasoning for the scope-out is in docs/testing.md → "Scripted playout
|
||||
/// coverage" and the decision record <c>testing.scripted-engine-in-process-net</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deviation from the three golden-file nets (docs/contributing.md §10): the expected output is a
|
||||
/// string constant in this file rather than a committed golden. The golden harness lives in
|
||||
/// <c>ErsatzTV.Core.Tests</c>, which cannot reference a controller, and duplicating it here would
|
||||
/// create a second action-to-engine mapping — the thing this design exists to avoid.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ScriptedScheduleControllerTests
|
||||
{
|
||||
private const string ContentKey = "content";
|
||||
private const string CollectionName = "Test Collection";
|
||||
|
||||
private static readonly Guid BuildId = Guid.Parse("00000000-0000-0000-0000-000000000563");
|
||||
private static readonly DateTimeOffset Start = new(2026, 1, 15, 6, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// What Startup hands to AddNewtonsoftJson, from the same function rather than a hand-copy of it.
|
||||
// The two Production_Body_Binder_* tests below are what hold this to the production configuration.
|
||||
private static readonly JsonSerializerSettings BodyBinderSettings = ApiJsonSettings.Create();
|
||||
|
||||
// A body that omits the `required` member "collection"; Newtonsoft and System.Text.Json disagree.
|
||||
private const string BodyMissingRequiredMember = """{"key":"content","order":"chronological"}""";
|
||||
|
||||
// A body sending "order" as an explicit null. NullValueHandling.Ignore keeps the DTO's declared
|
||||
// default; Newtonsoft's own default (Include) overwrites it with null.
|
||||
private const string BodyWithExplicitNullOrder =
|
||||
"""{"key":"content","collection":"Test Collection","order":null}""";
|
||||
|
||||
// Every action the fixture is expected to exercise. A fixture edit that drops one must fail loudly
|
||||
// rather than quietly shrinking what the snapshot covers.
|
||||
private static readonly string[] ExpectedActions =
|
||||
[
|
||||
"add_collection",
|
||||
"start_epg_group",
|
||||
"add_count",
|
||||
"stop_epg_group",
|
||||
"add_duration",
|
||||
"pad_until_exact",
|
||||
"add_all"
|
||||
];
|
||||
|
||||
// Raw UTC Start/Finish (never the *Offset properties, which localize), FillerKind, and the item's
|
||||
// MediaItemId rendered as "Movie NN" — a label built from the id, not a lookup of the seeded title.
|
||||
//
|
||||
// 004 and 006 are the two trimmed items, and they are the reason the fixture's numbers look arbitrary.
|
||||
// Movie 05 (90m) starts at 08:35 inside a two-hour add_duration ending 09:15, and pad_until_exact
|
||||
// targets 09:55, which Movie 01 (30m) would overshoot from 09:30. Land either target on a content
|
||||
// boundary instead and the trim branch never runs, leaving that action's `trim` argument unwitnessed.
|
||||
private const string ExpectedSnapshot =
|
||||
"""
|
||||
000 | 2026-01-15 06:00:00 - 2026-01-15 06:30:00 | None | Movie 01
|
||||
001 | 2026-01-15 06:30:00 - 2026-01-15 07:15:00 | None | Movie 02
|
||||
002 | 2026-01-15 07:15:00 - 2026-01-15 08:15:00 | None | Movie 03
|
||||
003 | 2026-01-15 08:15:00 - 2026-01-15 08:35:00 | None | Movie 04
|
||||
004 | 2026-01-15 08:35:00 - 2026-01-15 09:15:00 | None | Movie 05
|
||||
005 | 2026-01-15 09:15:00 - 2026-01-15 09:30:00 | None | Movie 06
|
||||
006 | 2026-01-15 09:30:00 - 2026-01-15 09:55:00 | None | Movie 01
|
||||
007 | 2026-01-15 09:55:00 - 2026-01-15 10:40:00 | None | Movie 02
|
||||
008 | 2026-01-15 10:40:00 - 2026-01-15 11:40:00 | None | Movie 03
|
||||
009 | 2026-01-15 11:40:00 - 2026-01-15 12:00:00 | None | Movie 04
|
||||
010 | 2026-01-15 12:00:00 - 2026-01-15 13:30:00 | None | Movie 05
|
||||
011 | 2026-01-15 13:30:00 - 2026-01-15 13:45:00 | None | Movie 06
|
||||
012 | 2026-01-15 13:45:00 - 2026-01-15 14:15:00 | None | Movie 01
|
||||
""";
|
||||
|
||||
[Test]
|
||||
public async Task Committed_Script_Fixture_Produces_The_Pinned_Snapshot()
|
||||
{
|
||||
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
|
||||
|
||||
List<string> replayed = await ReplayFixture(controller);
|
||||
replayed.ShouldBe(ExpectedActions);
|
||||
|
||||
List<PlayoutItem> items = engine.GetState().AddedItems;
|
||||
Snapshot(items).ShouldBe(Canonicalize(ExpectedSnapshot));
|
||||
|
||||
// invariants the snapshot alone does not state, so a regenerated snapshot cannot silently absorb them
|
||||
items.Count.ShouldBe(13);
|
||||
for (var i = 1; i < items.Count; i++)
|
||||
{
|
||||
items[i].Start.ShouldBe(items[i - 1].Finish);
|
||||
}
|
||||
|
||||
// start_epg_group ... stop_epg_group holds one guide group across its items and titles them
|
||||
items[0].GuideGroup.ShouldBe(items[1].GuideGroup);
|
||||
items[2].GuideGroup.ShouldNotBe(items[0].GuideGroup);
|
||||
items[0].CustomTitle.ShouldBe("Morning Block");
|
||||
items[1].CustomTitle.ShouldBe("Morning Block");
|
||||
items[2].CustomTitle.ShouldBeNull();
|
||||
|
||||
// after stop_epg_group every item opens its own guide group again
|
||||
items.Skip(2).Select(i => i.GuideGroup).ShouldBe(Enumerable.Range(2, 11));
|
||||
|
||||
// pad_until_exact lands on the requested instant, whatever the machine offset is, and gets there by
|
||||
// TRIMMING: Movie 01 is 30 minutes and starts at 09:30, so the 25-minute item below is the fixture's
|
||||
// `"trim": true` reaching the engine. Passing `false` for that argument drops the item entirely.
|
||||
items[6].Finish.ShouldBe(new DateTime(2026, 1, 15, 9, 55, 0, DateTimeKind.Utc));
|
||||
(items[6].Finish - items[6].Start).ShouldBe(TimeSpan.FromMinutes(25));
|
||||
items[6].OutPoint.ShouldBe(TimeSpan.FromMinutes(25));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Production_Body_Binder_Ignores_Required_Members()
|
||||
{
|
||||
// The replay above is only faithful while Bind uses MVC's own serializer, and "the two agree" is
|
||||
// not a safe assumption to leave unstated: Newtonsoft has no notion of the C# `required` keyword,
|
||||
// so a body omitting one deserializes to a default, where System.Text.Json rejects it outright.
|
||||
// The claim is about the SERIALIZER only — what MVC's validation layer then does with such a
|
||||
// body (non-nullable reference types pick up an implicit required check) is the uncovered
|
||||
// wrapper, not this. Binding through Bind itself is what gives this teeth: the fixture's own
|
||||
// bodies parse identically under either serializer, so only a body like this one separates them.
|
||||
// It separates THAT pair only — a Newtonsoft settings object that has merely lost the production
|
||||
// configuration reads this body exactly as production does, which is the sibling test's subject.
|
||||
using JsonDocument document = JsonDocument.Parse(BodyMissingRequiredMember);
|
||||
|
||||
ContentCollection bound = Bind<ContentCollection>(document.RootElement).ShouldNotBeNull();
|
||||
bound.Key.ShouldBe(ContentKey);
|
||||
bound.Collection.ShouldBeNull();
|
||||
|
||||
// the negative control: the same body under the serializer a swap would reach for
|
||||
Should.Throw<System.Text.Json.JsonException>(
|
||||
() => System.Text.Json.JsonSerializer.Deserialize<ContentCollection>(
|
||||
BodyMissingRequiredMember,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null()
|
||||
{
|
||||
// The other half of the binder-fidelity claim, and the half that a NEWTONSOFT lookalike witnesses:
|
||||
// production sets NullValueHandling.Ignore, so an explicit `"order": null` leaves ContentCollection
|
||||
// at its declared "shuffle" rather than overwriting it. Newtonsoft's own default is Include, which
|
||||
// writes the null through — and AddCollection's Enum.TryParse then rejects it as a 400. So this is
|
||||
// a behaviour difference a script would see, not a settings-shape assertion — which is what makes
|
||||
// the "shuffle" and the OkResult below assertions about the production configuration itself.
|
||||
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(BodyWithExplicitNullOrder);
|
||||
ContentCollection bound = Bind<ContentCollection>(document.RootElement).ShouldNotBeNull();
|
||||
bound.Order.ShouldBe("shuffle");
|
||||
|
||||
IActionResult result = await controller.AddCollection(BuildId, bound, CancellationToken.None);
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
|
||||
// and the content is usable, i.e. the surviving default really was a valid playback order
|
||||
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Unknown_Build_Id_Returns_404()
|
||||
{
|
||||
(ScriptedScheduleController controller, SchedulingEngine _) = NewSession();
|
||||
|
||||
var other = Guid.Parse("00000000-0000-0000-0000-000000000999");
|
||||
|
||||
controller.GetContext(other).Result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
|
||||
IActionResult collection = await controller.AddCollection(
|
||||
other,
|
||||
new ContentCollection { Key = ContentKey, Collection = CollectionName, Order = "chronological" },
|
||||
CancellationToken.None);
|
||||
collection.ShouldBeOfType<NotFoundObjectResult>();
|
||||
|
||||
ActionResult<PlayoutContext> count = controller.AddCount(
|
||||
other,
|
||||
new PlayoutCount { Content = ContentKey, Count = 1 });
|
||||
count.Result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Invalid_Playback_Order_Returns_400()
|
||||
{
|
||||
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
|
||||
|
||||
IActionResult result = await controller.AddCollection(
|
||||
BuildId,
|
||||
new ContentCollection { Key = ContentKey, Collection = CollectionName, Order = "nonsense" },
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
|
||||
// the 400 short-circuits before the engine sees the content
|
||||
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeFalse();
|
||||
engine.GetState().AddedItems.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Unknown_Filler_Kind_Falls_Back_To_None()
|
||||
{
|
||||
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
|
||||
|
||||
(await controller.AddCollection(
|
||||
BuildId,
|
||||
new ContentCollection { Key = ContentKey, Collection = CollectionName, Order = "chronological" },
|
||||
CancellationToken.None)).ShouldBeOfType<OkResult>();
|
||||
|
||||
// an unparseable filler_kind is NOT rejected the way an unparseable order is; it degrades to None
|
||||
ActionResult<PlayoutContext> result = controller.AddCount(
|
||||
BuildId,
|
||||
new PlayoutCount { Content = ContentKey, Count = 1, FillerKind = "not-a-filler-kind" });
|
||||
|
||||
result.Result.ShouldBeOfType<OkObjectResult>();
|
||||
engine.GetState().AddedItems.Single().FillerKind.ShouldBe(FillerKind.None);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Known_Filler_Kind_Reaches_The_Item()
|
||||
{
|
||||
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
|
||||
|
||||
(await controller.AddCollection(
|
||||
BuildId,
|
||||
new ContentCollection { Key = ContentKey, Collection = CollectionName, Order = "chronological" },
|
||||
CancellationToken.None)).ShouldBeOfType<OkResult>();
|
||||
|
||||
controller.AddCount(
|
||||
BuildId,
|
||||
new PlayoutCount { Content = ContentKey, Count = 1, FillerKind = "PreRoll" })
|
||||
.Result.ShouldBeOfType<OkObjectResult>();
|
||||
|
||||
engine.GetState().AddedItems.Single().FillerKind.ShouldBe(FillerKind.PreRoll);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void No_Progress_Throw_Is_Mapped_To_400()
|
||||
{
|
||||
(ScriptedScheduleController controller, SchedulingEngine _) = NewSession();
|
||||
|
||||
// GetContext reads IsDone, which halts a script that stops advancing time. The engine throws;
|
||||
// the controller must translate that into a 400 rather than letting it escape as a 500.
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
controller.GetContext(BuildId).Result.ShouldBeOfType<OkObjectResult>();
|
||||
}
|
||||
|
||||
controller.GetContext(BuildId).Result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Context_Reports_Start_Finish_And_Current_Time()
|
||||
{
|
||||
(ScriptedScheduleController controller, SchedulingEngine _) = NewSession();
|
||||
|
||||
var ok = controller.GetContext(BuildId).Result.ShouldBeOfType<OkObjectResult>();
|
||||
var context = ok.Value.ShouldBeOfType<PlayoutContext>();
|
||||
|
||||
context.StartTime.ToUniversalTime().ShouldBe(Start);
|
||||
context.FinishTime.ToUniversalTime().ShouldBe(Start.AddHours(12));
|
||||
context.CurrentTime.ToUniversalTime().ShouldBe(Start);
|
||||
context.IsDone.ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static (ScriptedScheduleController Controller, SchedulingEngine Engine) NewSession()
|
||||
{
|
||||
var repository = Substitute.For<IMediaCollectionRepository>();
|
||||
repository.GetCollectionItemsByName(CollectionName, Arg.Any<CancellationToken>())
|
||||
.Returns(_ => TestCollection());
|
||||
|
||||
var engine = new SchedulingEngine(
|
||||
repository,
|
||||
Substitute.For<IGraphicsElementRepository>(),
|
||||
Substitute.For<IChannelRepository>(),
|
||||
Substitute.For<ILogger<SchedulingEngine>>());
|
||||
|
||||
// same setup order ScriptedPlayoutBuilder uses; WithReferenceData must precede RestoreOrReset
|
||||
engine.WithPlayoutId(1)
|
||||
.WithMode(PlayoutBuildMode.Reset)
|
||||
.WithSeed(0)
|
||||
.BuildBetween(Start, Start.AddHours(12))
|
||||
.WithReferenceData(new PlayoutReferenceData(null, Option<Deco>.None, [], [], null, [], [], TimeSpan.Zero))
|
||||
.RestoreOrReset(Option<PlayoutAnchor>.None);
|
||||
|
||||
// a fresh service per test, so no session leaks between fixtures
|
||||
var service = new ScriptedPlayoutBuilderService();
|
||||
service.MockSession(engine, BuildId).ShouldBeTrue();
|
||||
|
||||
return (new ScriptedScheduleController(service), engine);
|
||||
}
|
||||
|
||||
// Deserializes each fixture entry into the controller's own request DTO and invokes the matching action,
|
||||
// asserting a 200 each time. Returns the actions actually replayed, so the caller can assert coverage.
|
||||
private static async Task<List<string>> ReplayFixture(ScriptedScheduleController controller)
|
||||
{
|
||||
string json = await File.ReadAllTextAsync(Path.Combine(FixtureDir(), "scripted-build.json"));
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(json);
|
||||
var replayed = new List<string>();
|
||||
|
||||
foreach (JsonElement entry in document.RootElement.GetProperty("script").EnumerateArray())
|
||||
{
|
||||
string action = entry.GetProperty("action").GetString().ShouldNotBeNull();
|
||||
JsonElement body = entry.TryGetProperty("body", out JsonElement maybeBody) ? maybeBody : default;
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case "add_collection":
|
||||
ShouldBeOk(
|
||||
await controller.AddCollection(
|
||||
BuildId,
|
||||
Bind<ContentCollection>(body),
|
||||
CancellationToken.None));
|
||||
break;
|
||||
case "start_epg_group":
|
||||
ShouldBeOk(controller.StartEpgGroup(BuildId, Bind<ControlStartEpgGroup>(body)));
|
||||
break;
|
||||
case "stop_epg_group":
|
||||
ShouldBeOk(controller.StopEpgGroup(BuildId));
|
||||
break;
|
||||
case "add_count":
|
||||
ShouldBeOkContext(controller.AddCount(BuildId, Bind<PlayoutCount>(body)));
|
||||
break;
|
||||
case "add_all":
|
||||
ShouldBeOkContext(controller.AddAll(BuildId, Bind<ContentAll>(body)));
|
||||
break;
|
||||
case "add_duration":
|
||||
ShouldBeOkContext(controller.AddDuration(BuildId, Bind<PlayoutDuration>(body)));
|
||||
break;
|
||||
case "pad_until_exact":
|
||||
ShouldBeOkContext(controller.PadUntilExact(BuildId, Bind<PlayoutPadUntilExact>(body)));
|
||||
break;
|
||||
default:
|
||||
Assert.Fail($"Fixture uses action '{action}', which the replayer does not implement.");
|
||||
break;
|
||||
}
|
||||
|
||||
replayed.Add(action);
|
||||
}
|
||||
|
||||
return replayed;
|
||||
}
|
||||
|
||||
// Deserializes a fixture body with the configuration the production body binder runs
|
||||
// (Startup -> AddNewtonsoftJson -> ApiJsonSettings), so the fixture's field names and casing are
|
||||
// asserted against that serializer rather than a convenient one. Not the identical settings OBJECT:
|
||||
// MVC applies the configuration to its own pre-configured settings, so its stricter MaxDepth and its
|
||||
// two ProblemDetails converters are absent from Create() — ApiJsonSettingsTests enumerates the gap and
|
||||
// shows why neither reaches a request body. The two Production_Body_Binder_* tests, not this helper,
|
||||
// are what make a replacement of the configuration visible.
|
||||
private static T Bind<T>(JsonElement body) where T : class
|
||||
{
|
||||
body.ValueKind.ShouldBe(JsonValueKind.Object);
|
||||
return JsonConvert.DeserializeObject<T>(body.GetRawText(), BodyBinderSettings).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
private static void ShouldBeOk(IActionResult result) => result.ShouldBeOfType<OkResult>();
|
||||
|
||||
private static void ShouldBeOkContext(ActionResult<PlayoutContext> result)
|
||||
{
|
||||
var ok = result.Result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldBeOfType<PlayoutContext>();
|
||||
}
|
||||
|
||||
// Same line format as PlayoutBuildGoldenTests.Snapshot: ordered by Start then MediaItemId, raw UTC.
|
||||
private static string Snapshot(List<PlayoutItem> items)
|
||||
{
|
||||
List<PlayoutItem> ordered = items
|
||||
.OrderBy(i => i.Start)
|
||||
.ThenBy(i => i.MediaItemId)
|
||||
.ToList();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (var index = 0; index < ordered.Count; index++)
|
||||
{
|
||||
PlayoutItem item = ordered[index];
|
||||
sb.Append(index.ToString("D3", CultureInfo.InvariantCulture));
|
||||
sb.Append(" | ");
|
||||
sb.Append(item.Start.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
|
||||
sb.Append(" - ");
|
||||
sb.Append(item.Finish.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
|
||||
sb.Append(" | ");
|
||||
sb.Append(item.FillerKind.ToString());
|
||||
sb.Append(" | ");
|
||||
sb.Append($"Movie {item.MediaItemId:D2}");
|
||||
sb.Append('\n');
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string Canonicalize(string text) => text.ReplaceLineEndings("\n").TrimEnd('\n') + "\n";
|
||||
|
||||
private static string FixtureDir([CallerFilePath] string thisFile = "") =>
|
||||
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Fixtures");
|
||||
|
||||
// Distinct release dates make chronological order deterministic (id order); distinct durations make
|
||||
// every boundary in the snapshot unambiguous.
|
||||
private static List<MediaItem> TestCollection() =>
|
||||
[
|
||||
FakeMovie(1, 30),
|
||||
FakeMovie(2, 45),
|
||||
FakeMovie(3, 60),
|
||||
FakeMovie(4, 20),
|
||||
FakeMovie(5, 90),
|
||||
FakeMovie(6, 15)
|
||||
];
|
||||
|
||||
private static Movie FakeMovie(int id, int minutes) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(minutes) }],
|
||||
MovieMetadata =
|
||||
[
|
||||
new MovieMetadata
|
||||
{
|
||||
Title = $"Movie {id:D2}",
|
||||
ReleaseDate = new DateTime(2005, 1, 1).AddDays(id)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -35,7 +35,7 @@ public class GraphicsElementDefaultAttachTests
|
||||
{
|
||||
var element = new GraphicsElement
|
||||
{
|
||||
Path = $"/templates/text/{GraphicsElementDefaults.OnNowNextFileName}",
|
||||
Path = GraphicsElementDefaults.OnNowNextSeededPath,
|
||||
Name = "On Now / Next",
|
||||
Kind = GraphicsElementKind.Text
|
||||
};
|
||||
@@ -153,17 +153,36 @@ public class GraphicsElementDefaultAttachTests
|
||||
(await AttachedElementIds(context, channel.Id)).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
// Filename alone is ambiguous: the five template folders are separate namespaces, so an element
|
||||
// of another kind may legitimately carry the same filename.
|
||||
[Test]
|
||||
public async Task Ignores_A_Same_Named_Element_Of_A_Different_Kind()
|
||||
public async Task Ignores_A_Non_Built_In_Element_With_A_Different_Filename()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
await context.GraphicsElements.AddAsync(
|
||||
new GraphicsElement { Path = "/templates/text/something-else.yml", Kind = GraphicsElementKind.Text });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
Channel channel = await SeedChannel(context, "1");
|
||||
|
||||
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
|
||||
|
||||
(await AttachedElementIds(context, channel.Id)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// #568: filename-only matching was folder-agnostic -- a user Text element named exactly
|
||||
// "on-now-next.yml" outside the seeded text-template folder used to also count as built-in.
|
||||
// Same Kind as the real seeded row, deliberately a different folder, so this only reddens if
|
||||
// GetBuiltInElementId goes back to comparing Path.GetFileName(...) instead of the full path.
|
||||
[Test]
|
||||
public async Task Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
await context.GraphicsElements.AddAsync(
|
||||
new GraphicsElement
|
||||
{
|
||||
Path = $"/templates/image/{GraphicsElementDefaults.OnNowNextFileName}",
|
||||
Kind = GraphicsElementKind.Image
|
||||
Path = System.IO.Path.Combine(
|
||||
"/config/graphics-elements/text/some-subfolder",
|
||||
GraphicsElementDefaults.OnNowNextFileName),
|
||||
Kind = GraphicsElementKind.Text
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
@@ -174,12 +193,23 @@ public class GraphicsElementDefaultAttachTests
|
||||
(await AttachedElementIds(context, channel.Id)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// #568: GetBuiltInElementId resolves through GraphicsElementDefaults.IsOnNowNext, an ORDINAL
|
||||
// comparison, so a Text row in the seeded folder whose filename differs only in case is a
|
||||
// different element. Reddens if that comparison is loosened to OrdinalIgnoreCase. It does NOT
|
||||
// pin provider independence -- under SQLite's BINARY collation an equivalent SQL `Where` answers
|
||||
// identically; that is held by keeping the comparison in memory (see IsOnNowNext's remarks).
|
||||
[Test]
|
||||
public async Task Ignores_A_Non_Built_In_Element_With_A_Different_Filename()
|
||||
public async Task Ignores_A_Case_Variant_Of_The_Seeded_Path()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
await context.GraphicsElements.AddAsync(
|
||||
new GraphicsElement { Path = "/templates/text/something-else.yml", Kind = GraphicsElementKind.Text });
|
||||
new GraphicsElement
|
||||
{
|
||||
Path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetDirectoryName(GraphicsElementDefaults.OnNowNextSeededPath)!,
|
||||
GraphicsElementDefaults.OnNowNextFileName.ToUpperInvariant()),
|
||||
Kind = GraphicsElementKind.Text
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
Channel channel = await SeedChannel(context, "1");
|
||||
@@ -242,6 +272,54 @@ public class GraphicsElementDefaultAttachTests
|
||||
(await GraphicsElementSeeder.GetBuiltInElementId(context, CancellationToken.None)).IsSome.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// #568: EnsureBuiltInElementRow's "does it exist already?" test and the lookup every consumer
|
||||
// resolves through must be the same question. While that test was its own SQL comparison
|
||||
// (`e.Path == target`), a row at the seeded path of ANY kind answered yes, so the Text row
|
||||
// GetBuiltInElementId actually matches was never created and the built-in element stayed
|
||||
// undiscoverable for the life of the install.
|
||||
//
|
||||
// This is the half of that divergence SQLite can observe. The other half -- a case-variant path,
|
||||
// which MySQL's normally case-insensitive default collation matches in SQL while the ordinal
|
||||
// lookup never does -- cannot be shown from a test here, because SQLite's BINARY collation and
|
||||
// an ordinal comparison agree on every input; it is held by keeping the comparison out of SQL.
|
||||
//
|
||||
// RefreshGraphicsElements derives Kind from the template FOLDER, so it cannot itself produce the
|
||||
// wrong-kind row seeded below. The point is not that this state is common but that the seeder
|
||||
// must not answer a question differently from the code that consumes its answer.
|
||||
[Test]
|
||||
public async Task A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
|
||||
string target = Path.Combine(
|
||||
FileSystemLayout.GraphicsElementsTextTemplatesFolder,
|
||||
GraphicsElementDefaults.OnNowNextFileName);
|
||||
await fs.File.WriteAllTextAsync(target, "name: On Now / Next\n");
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.ConfigElements.Add(
|
||||
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
|
||||
await context.GraphicsElements.AddAsync(
|
||||
new GraphicsElement { Path = target, Kind = GraphicsElementKind.Image });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
|
||||
|
||||
List<GraphicsElement> rows = await context.GraphicsElements.OrderBy(e => e.Id).ToListAsync();
|
||||
rows.Count.ShouldBe(2, "the built-in Text row must be created alongside the wrong-kind row");
|
||||
rows[1].Path.ShouldBe(target);
|
||||
rows[1].Kind.ShouldBe(GraphicsElementKind.Text);
|
||||
rows[1].Name.ShouldBe(GraphicsElementDefaults.OnNowNextName);
|
||||
|
||||
// The lookup must land on the Text row, not on the lower-id wrong-kind row that shares its
|
||||
// path. Both clauses this test covers are measured rows of the mutation table in
|
||||
// docs/graphics-elements.md -- 18 (the Kind filter) and 34 (the existence check re-derived
|
||||
// in SQL) -- rather than an outcome asserted only here.
|
||||
int builtInId = (await GraphicsElementSeeder.GetBuiltInElementId(context, CancellationToken.None))
|
||||
.IfNone(-1);
|
||||
builtInId.ShouldBe(rows[1].Id);
|
||||
}
|
||||
|
||||
// The armed path is a real, reachable state: an operator deletes the template, refresh reaps the
|
||||
// row, and the backfill then has nothing to resolve. Pin what happens when the element comes
|
||||
// back -- a single global marker cannot both avoid stranding and avoid re-adding, and this is
|
||||
|
||||
@@ -33,7 +33,7 @@ namespace ErsatzTV.Tests.Integration;
|
||||
/// the decision record <c>media.nullable-primitive-collection-mutation</c>.
|
||||
/// Assigning one on a TRACKED entity flips it to <see cref="EntityState.Modified" />, and the next
|
||||
/// <c>SaveChanges</c> writes <c>[]</c> over what the database held as <c>NULL</c> — the exact
|
||||
/// mechanism an adversarial review demonstrated in ersatztv#691, which is why that issue's
|
||||
/// mechanism demonstrated in ersatztv#691, which is why that issue's
|
||||
/// entity-level guard was reverted in favour of guarding at the READ SITE.
|
||||
/// </para>
|
||||
/// <para>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using ErsatzTV.Serialization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the gap between the two objects <see cref="ApiJsonSettings" /> produces:
|
||||
/// <see cref="ApiJsonSettings.Apply" /> onto MVC's own settings, which is what the production body binder
|
||||
/// runs, and <see cref="ApiJsonSettings.Create" />, which tests outside the MVC pipeline
|
||||
/// (<c>ScriptedScheduleControllerTests</c>, <c>OpenApiSerializerContractTests</c>) bind and serialize with.
|
||||
/// They are not the same object, and the difference belongs in a test rather than in a comment: a prose
|
||||
/// claim of parity cannot notice the day it stops being true, and every doc describing the scripted
|
||||
/// coverage rests on knowing which parts carry over and which do not.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ApiJsonSettingsTests
|
||||
{
|
||||
// The object MVC's options pipeline constructs before Startup's configure delegate runs. Building it
|
||||
// directly rather than hosting the app is the point of the caveat below: this measures the settings
|
||||
// MvcNewtonsoftJsonOptions itself defines, not a fully composed host.
|
||||
private static JsonSerializerSettings Production() =>
|
||||
ApiJsonSettings.Apply(new MvcNewtonsoftJsonOptions().SerializerSettings);
|
||||
|
||||
[Test]
|
||||
public void Apply_Sets_The_Same_Four_Things_On_Either_Object()
|
||||
{
|
||||
JsonSerializerSettings production = Production();
|
||||
JsonSerializerSettings standalone = ApiJsonSettings.Create();
|
||||
|
||||
production.NullValueHandling.ShouldBe(NullValueHandling.Ignore);
|
||||
standalone.NullValueHandling.ShouldBe(NullValueHandling.Ignore);
|
||||
|
||||
production.ReferenceLoopHandling.ShouldBe(ReferenceLoopHandling.Ignore);
|
||||
standalone.ReferenceLoopHandling.ShouldBe(ReferenceLoopHandling.Ignore);
|
||||
|
||||
production.ContractResolver.ShouldBeOfType<CustomContractResolver>();
|
||||
standalone.ContractResolver.ShouldBeOfType<CustomContractResolver>();
|
||||
|
||||
production.Converters.ShouldContain(c => c is Newtonsoft.Json.Converters.StringEnumConverter);
|
||||
standalone.Converters.ShouldContain(c => c is Newtonsoft.Json.Converters.StringEnumConverter);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Standalone_Matches_On_The_Mvc_Defaults_That_Change_How_A_Body_Parses()
|
||||
{
|
||||
JsonSerializerSettings production = Production();
|
||||
JsonSerializerSettings standalone = ApiJsonSettings.Create();
|
||||
|
||||
standalone.MissingMemberHandling.ShouldBe(production.MissingMemberHandling);
|
||||
standalone.TypeNameHandling.ShouldBe(production.TypeNameHandling);
|
||||
standalone.DateParseHandling.ShouldBe(production.DateParseHandling);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Standalone_Does_Not_Carry_Mvcs_Depth_Limit_Or_Its_Error_Converters()
|
||||
{
|
||||
JsonSerializerSettings production = Production();
|
||||
JsonSerializerSettings standalone = ApiJsonSettings.Create();
|
||||
|
||||
// MVC clamps the object graph it will consume; a bare settings object keeps Newtonsoft's own limit.
|
||||
// A body deeper than 32 therefore binds here and 400s in production — inert for the scripted request
|
||||
// DTOs, which nest two levels, and the reason no test may generalize from Create() to "production".
|
||||
production.MaxDepth.ShouldBe(32);
|
||||
standalone.MaxDepth.ShouldBe(64);
|
||||
|
||||
// The rest of the delta is MVC's error-response shaping, which no request body reaches.
|
||||
string[] missing = production.Converters
|
||||
.Select(c => c.GetType().Name)
|
||||
.Except(standalone.Converters.Select(c => c.GetType().Name))
|
||||
.Order()
|
||||
.ToArray();
|
||||
|
||||
missing.ShouldBe(["ProblemDetailsConverter", "ValidationProblemDetailsConverter"]);
|
||||
|
||||
// and nothing goes the other way: the standalone object adds nothing production lacks
|
||||
standalone.Converters
|
||||
.Select(c => c.GetType().Name)
|
||||
.Except(production.Converters.Select(c => c.GetType().Name))
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
|
||||
namespace ErsatzTV.Tests.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Makes the next <c>SaveChangesAsync</c> fail the way a lost race against a concurrent delete
|
||||
/// does: run <see cref="SqlBeforeFailing" /> first (the delete), then throw the
|
||||
/// <see cref="DbUpdateException" /> the foreign-key violation would have produced.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What is simulated is the TRIGGER, not the decision: <see cref="InMemoryTvContext" /> runs
|
||||
/// with foreign keys OFF, so a real constraint cannot fire here. The handler's recovery — re-ask
|
||||
/// the existence question against the post-delete state, translate a now-missing id into the
|
||||
/// validator's own 422, re-throw anything else — is real code running against real data.
|
||||
/// Disarms itself on the first fire so the recovery path gets a working context.
|
||||
/// </remarks>
|
||||
public sealed class ArmedSaveFailureInterceptor : SaveChangesInterceptor
|
||||
{
|
||||
public bool Armed { get; set; }
|
||||
|
||||
public string SqlBeforeFailing { get; set; } = string.Empty;
|
||||
|
||||
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
|
||||
DbContextEventData eventData,
|
||||
InterceptionResult<int> result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!Armed)
|
||||
{
|
||||
return await base.SavingChangesAsync(eventData, result, cancellationToken);
|
||||
}
|
||||
|
||||
Armed = false;
|
||||
|
||||
if (!string.IsNullOrEmpty(SqlBeforeFailing))
|
||||
{
|
||||
await eventData.Context!.Database.ExecuteSqlRawAsync(SqlBeforeFailing, cancellationToken);
|
||||
}
|
||||
|
||||
throw new DbUpdateException("simulated: a validated principal row was deleted concurrently");
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,8 @@ public abstract class ChannelHandlerTestBase
|
||||
string logoPath = "",
|
||||
string name = "Test",
|
||||
string group = "ErsatzTV",
|
||||
List<int> graphicsElementIds = null) =>
|
||||
List<int> graphicsElementIds = null,
|
||||
int? watermarkId = null) =>
|
||||
new(
|
||||
channelId,
|
||||
name,
|
||||
@@ -147,7 +148,7 @@ public abstract class ChannelHandlerTestBase
|
||||
null,
|
||||
null,
|
||||
StreamingMode.TransportStreamHybrid,
|
||||
null,
|
||||
watermarkId,
|
||||
null,
|
||||
string.Empty,
|
||||
ChannelSubtitleMode.None,
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Sqlite.Data;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ErsatzTV.Tests.Support;
|
||||
@@ -27,7 +28,13 @@ public sealed class InMemoryTvContext : IAsyncDisposable
|
||||
|
||||
public IDbContextFactory<TvContext> Factory => new TestDbContextFactory(_options);
|
||||
|
||||
public static async Task<InMemoryTvContext> CreateAsync()
|
||||
/// <param name="interceptors">
|
||||
/// Extra EF interceptors registered on every context this harness hands out. The only
|
||||
/// current use is making <c>SaveChangesAsync</c> fail on demand: foreign keys are off here
|
||||
/// (see the type summary), so a write-path failure a real FK constraint would raise cannot
|
||||
/// be produced by seeding alone.
|
||||
/// </param>
|
||||
public static async Task<InMemoryTvContext> CreateAsync(params IInterceptor[] interceptors)
|
||||
{
|
||||
TvContext.IsSqlite = true;
|
||||
TvContext.IsUniqueConstraintViolation = SqliteErrorClassifier.IsUniqueConstraintViolation;
|
||||
@@ -38,6 +45,7 @@ public sealed class InMemoryTvContext : IAsyncDisposable
|
||||
|
||||
DbContextOptions<TvContext> options = new DbContextOptionsBuilder<TvContext>()
|
||||
.UseSqlite(connection)
|
||||
.AddInterceptors(interceptors)
|
||||
.Options;
|
||||
|
||||
await using (TvContext context = Create(options))
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace ErsatzTV.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// The Newtonsoft settings the MVC JSON formatters read and write `/api/*` bodies with. Defined once,
|
||||
/// here, so a test can deserialize a request body exactly the way the production body binder does
|
||||
/// instead of mirroring <see cref="Startup" />'s registration. This REMOVES the duplicate rather than
|
||||
/// detecting drift in one: no test observes <c>Startup.ConfigureServices</c>, and a byte-equal hand-copy
|
||||
/// of <see cref="Apply" /> is behaviourally indistinguishable from calling it. What the tests do
|
||||
/// separate is a caller that binds with something else — a body omitting a C#
|
||||
/// <c>required</c> member deserializes to a default here where System.Text.Json throws, and
|
||||
/// <see cref="NullValueHandling" />.<c>Ignore</c> keeps a DTO's declared default over an explicit
|
||||
/// null where plain Newtonsoft writes the null through. Both are pinned by
|
||||
/// <c>ScriptedScheduleControllerTests</c>; the naming strategy is pinned on the write side by
|
||||
/// <c>OpenApiSerializerContractTests</c>. See docs/testing.md → "Scripted playout coverage".
|
||||
/// </summary>
|
||||
public static class ApiJsonSettings
|
||||
{
|
||||
/// <summary>Applies the production configuration to an existing settings object (the MVC path).</summary>
|
||||
public static JsonSerializerSettings Apply(JsonSerializerSettings settings)
|
||||
{
|
||||
settings.NullValueHandling = NullValueHandling.Ignore;
|
||||
settings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
|
||||
settings.ContractResolver = new CustomContractResolver();
|
||||
settings.Converters.Add(new StringEnumConverter());
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="Apply" /> onto a bare settings object, for a caller outside the MVC pipeline. It carries
|
||||
/// everything <see cref="Apply" /> sets and nothing MVC supplies around it, so it is NOT the object the
|
||||
/// body binder runs. Measured 2026-09-05 against <c>new MvcNewtonsoftJsonOptions().SerializerSettings</c>:
|
||||
/// <c>MaxDepth</c> is Newtonsoft's 64 rather than MVC's stricter 32, and MVC's two error-shaping
|
||||
/// converters — <c>ProblemDetailsConverter</c> and <c>ValidationProblemDetailsConverter</c> — are absent.
|
||||
/// <c>MissingMemberHandling</c>, <c>TypeNameHandling</c> and <c>DateParseHandling</c> match. Both gaps are
|
||||
/// inert for the request DTOs the tests bind (two levels deep, never a <c>ProblemDetails</c>), which is why
|
||||
/// this is usable there at all; the delta is pinned by <c>ApiJsonSettingsTests</c> so it cannot decay into
|
||||
/// a parity claim.
|
||||
/// </summary>
|
||||
public static JsonSerializerSettings Create() => Apply(new JsonSerializerSettings());
|
||||
}
|
||||
+3
-8
@@ -104,7 +104,6 @@ using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.IO;
|
||||
using Microsoft.OpenApi;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Refit;
|
||||
using Scalar.AspNetCore;
|
||||
using Serilog;
|
||||
@@ -503,13 +502,9 @@ public class Startup
|
||||
options.OutputFormatters.Insert(0, new HdhrJsonOutputFormatter());
|
||||
options.Filters.AddService<ApiAuthorizationFilter>();
|
||||
})
|
||||
.AddNewtonsoftJson(opt =>
|
||||
{
|
||||
opt.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
|
||||
opt.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
|
||||
opt.SerializerSettings.ContractResolver = new CustomContractResolver();
|
||||
opt.SerializerSettings.Converters.Add(new StringEnumConverter());
|
||||
});
|
||||
// ApiJsonSettings rather than an inline lambda: the request-body binder's semantics are asserted
|
||||
// by ScriptedScheduleControllerTests, which has to configure the SAME object, not a mirror of it.
|
||||
.AddNewtonsoftJson(opt => ApiJsonSettings.Apply(opt.SerializerSettings));
|
||||
|
||||
services.AddScoped(_ => new ConditionalIptvAuthorizeFilter("JwtOnlyScheme"));
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ Tracked under epic **#62**; sub-issues #63–#68:
|
||||
|
||||
## 7. Out of scope & next steps
|
||||
|
||||
**Out of scope now:** the full #59 implementation spec, React app architecture (routing/state/auth/build/deploy), the media-libraries/collections browser (Pass 2), browser-based channel playback (tracked separately in #60), the actual rebuild. (Channel creation was originally deferred to Pass 2 but is now specified in §6.)
|
||||
**Out of scope now:** the full #59 implementation spec, React app architecture (routing/state/auth/build/deploy), the media-libraries/collections browser (Pass 2), browser-based channel playback (tracked separately in #60), the actual rebuild. (Channel creation is specified in §6, not deferred to Pass 2.)
|
||||
|
||||
**Next steps (after a look is locked):**
|
||||
1. Design the §6 Create Channel screen in Claude Design; file the §6 backend-gap issues.
|
||||
|
||||
+35
-31
@@ -9,37 +9,41 @@ WORKDIR /source
|
||||
COPY design-system/. ./design-system/
|
||||
COPY web/. ./web/
|
||||
WORKDIR /source/web
|
||||
# The SPA suite runs here except for two files (ersatztv#819), excluded for OVERLAPPING reasons —
|
||||
# one needs the git binary, the other needs the binary AND a checkout. Reading them as two separate
|
||||
# reasons is what broke this stage once already:
|
||||
# * `web/src/api/pageSizeCallSites.guard.test.ts` needs a git CHECKOUT — and, through it, the
|
||||
# binary. It derives its file population from `git ls-files` rather than a directory walk, and
|
||||
# refuses rather than falling back. This stage's context is `web/` + `design-system/` only, so
|
||||
# there is no `.git`.
|
||||
# * `web/vite-plugins/trackedSourceFiles.realgit.test.ts` needs the BINARY but no checkout: it
|
||||
# builds its own temp repository to prove the derivation by executing it.
|
||||
# `node:22-bookworm-slim` ships no git (`command -v git` -> not found), so it dies with
|
||||
# `spawnSync git ENOENT`.
|
||||
# So this is NOT checkout-versus-binary, and adding a `COPY .git` would not let either run here —
|
||||
# the binary would still be missing. Excluding only the first is not enough either, and a replica
|
||||
# that merely deletes `.git` cannot show that: verify any change here with the git binary off
|
||||
# `PATH`, not just with the directory absent.
|
||||
# Everything else — all but those two files — runs fine gitless and is kept, rather than dropping
|
||||
# the whole suite for one file as an earlier fix here did.
|
||||
# The excluded pair is not skipped overall: `docker-build.yml`'s `Build & test (.NET)` job runs the
|
||||
# whole suite on a real checkout, and `build` (the job that invokes this Dockerfile) carries
|
||||
# `needs: [test, migrations, scan]`. State that chain precisely, because the `needs:` edge is not
|
||||
# all of it: `Test SPA` is also gated on `docs_only` and on the #420 revalidate skip, and `build` is
|
||||
# not gated on `revalidate`. On a push whose tree is byte-identical to an already-green head the
|
||||
# suite is skipped and the image still builds — carried there by #420's byte-identical-tree
|
||||
# argument. The `docs_only` arm cannot ship an image at all (`Build and push` is gated on it too),
|
||||
# and `ci-detect-docs-only.sh` classifies by PATH SHAPE (`docs/` or `*.md`), not by directory, so a
|
||||
# `web/*.md` would count as docs — there are none today, but do not restate it as "any `web/**`".
|
||||
RUN npm run lint && npm run typecheck && \
|
||||
npm test -- --run \
|
||||
--exclude 'src/api/pageSizeCallSites.guard.test.ts' \
|
||||
--exclude 'vite-plugins/trackedSourceFiles.realgit.test.ts' && \
|
||||
npm run build
|
||||
# THE VITEST SUITE IS NOT RUN IN THIS STAGE, AND MUST NOT BE RE-ADDED (ersatztv#887).
|
||||
# This stage is gitless in both senses at once. It copies only `web/` and `design-system/`, so it
|
||||
# holds no `.git` — a property of the STAGE, not of the context, which is the repository root
|
||||
# (`context: .`) and whose `.dockerignore` does not exclude `.git`: the directory is there to be
|
||||
# copied and simply is not. And `node:22-bookworm-slim` ships no git binary (`command -v git` -> not
|
||||
# found), which is why a `COPY .git` would not help either. Members of the SPA suite need one or the
|
||||
# other: `pageSizeCallSites.guard.test.ts` and `completeAnnotations.guard.test.ts` derive their file
|
||||
# population from `git ls-files` and refuse to fall back to a directory walk
|
||||
# (`testing.guard-derives-population-from-source`), and `trackedSourceFiles.realgit.test.ts` builds
|
||||
# its own temp repository. (Not every `*.guard.test.ts` — `completeRequest.guard.test.ts` needs no
|
||||
# git at all.)
|
||||
#
|
||||
# Running the suite here anyway therefore costs a hand-maintained list of the members that cannot
|
||||
# run — and that list is a population nothing derives. It went stale the first time a guard was
|
||||
# added without updating it, and because `Build & push image (amd64)` is `if: github.event_name !=
|
||||
# 'pull_request'`, the resulting red is unreachable on a PR and lands on `main` and on the `v*` tag
|
||||
# path: every image build failed and `:latest` stopped being republished. Excluding one more file
|
||||
# re-arms that; the list is removed instead.
|
||||
#
|
||||
# WHAT STILL VALIDATES THE SUITE. `docker-build.yml`'s `test` job runs it UNFILTERED on a real
|
||||
# checkout, and `build` — the job that invokes this Dockerfile — carries `needs: [test, migrations,
|
||||
# scan]`, so an image cannot be published past a red suite. Two skips exist inside `test` and
|
||||
# neither leaves an image unvalidated: `docs_only` also gates `Build and push`, so that arm ships
|
||||
# nothing — and that arm IS asserted, by `test_the_DOCS_ONLY_arm_cannot_publish_an_image`. The #420
|
||||
# revalidate skip fires only on a tree byte-identical to a head that already carried a green combined
|
||||
# status; that arm is NOT asserted here — it rests on `scripts/ci-detect-already-validated.sh`, which
|
||||
# carries no mutation proof of its own, so it is a dependency rather than a check. Held by
|
||||
# `scripts/tests/test_image_build_delegates_the_spa_suite.py`. That guard PINS the command line
|
||||
# below as text: any edit to it reddens, whatever the edit says. Update the pin in the same commit,
|
||||
# and do not use it to re-add a suite run.
|
||||
#
|
||||
# `lint` and `typecheck` stay. They are gitless-safe with no member that is not, so they carry no
|
||||
# list and no trap; whether the image build should run them at all is a separate question this
|
||||
# does not answer.
|
||||
RUN npm run lint && npm run typecheck && npm run build
|
||||
|
||||
FROM --platform=linux/amd64 192.168.1.95:3000/timothy/ersatztv-ffmpeg:8.1.2 AS runtime-base
|
||||
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
|
||||
|
||||
+5
-1
@@ -17,6 +17,7 @@ doc below, or that changes which sections a task signal points to.**
|
||||
| Signal | Read |
|
||||
| --- | --- |
|
||||
| Session startup / "what's next" (no issue named) | `docs/handoffs/chicorytv-issue-queue.md` (standing kickoff — two concurrent tracks: orientation ‖ `scripts/select-queue.sh 5`) |
|
||||
| Running SEVERAL issues in parallel under one orchestrator (the `orchestrator-prompt.md` kickoff) | `docs/handoffs/orchestration.md` — roles and sizing, one worktree per issue under `~/orca/workspaces/ersatztv/`, the landing order (gate + review before the single push; verdict via `scripts/post-review-verdict.sh`; merge through the consent hook), resuming a paused branch; rule: `process.orchestrated-session`. The queue rules and HARD CONSTRAINTS stay in `chicorytv-issue-queue.md` |
|
||||
| Named-issue pickup | Skip queue selection; go straight to focused retrieval — see "Knowledge retrieval" below, then the issue body |
|
||||
| Adding/changing a `/api/*` endpoint | `docs/api-conventions.md` checklist + `docs/endpoint-index.md` |
|
||||
| Adding a ChicoryTV SPA screen | `docs/spa-conventions.md` |
|
||||
@@ -27,8 +28,9 @@ doc below, or that changes which sections a task signal points to.**
|
||||
| Concurrency / optimistic-locking work | `docs/api-conventions.md` §7a/b/c + `docs/decisions/optimistic-concurrency.md` |
|
||||
| Auth / security-surface work | `docs/decisions/api-auth-security.md` |
|
||||
| CI / release pipeline work | `docs/ci-cd.md` + `docs/decisions/release-ci-governance.md` |
|
||||
| Proposing a new guard / CI check / regression test convention | `docs/defect-shapes-773.md` §4 (detector menu + the classes where no detector is plausible), then the rules every guard must satisfy: `docs/decisions/records/testing/guard-derives-population-from-source.md`, `…/guard-ships-with-mutation-proof.md` and `…/mutation-claims-are-executed.md` (a `MUTATION` grade carries a DECLARED clause mutation that is re-run every suite) — plus `…/verification-code-needs-its-own-proof.md`, which extends the same obligation BEYOND guards to the harness, wrapper or checker doing the checking, and says where its proof lives when the checker holds no row |
|
||||
| Proposing a new guard / CI check / regression test convention | `docs/defect-shapes-773.md` §4 (detector menu + the classes where no detector is plausible), then the rules every guard must satisfy: `docs/decisions/records/testing/guard-derives-population-from-source.md`, `…/guard-ships-with-mutation-proof.md` and `…/mutation-claims-are-executed.md` (a `MUTATION` grade carries a DECLARED clause mutation that is re-run every suite — and so does a PROSE claim that some mutation reddens, or does not redden, a named test, wherever it is written: it is a `CLAIMS` entry in the same manifest, bound to its site and verbatim quote, or it is not written) — plus `…/verification-code-needs-its-own-proof.md`, which extends the same obligation BEYOND guards to the harness, wrapper or checker doing the checking, and says where its proof lives when the checker holds no row. Then `…/guard-pins-the-artifact-not-a-shape.md` for the SHAPE of the predicate itself: over an artifact whose GRAMMAR the predicate does not implement, pin the artifact WHOLE by default — matching a shape inside it is the exception and must carry that record's four-part argument; that record's `rule:` is the one place the qualifying grammars are enumerated |
|
||||
| Adding or bounding a consequential numeric config field (an FFmpeg profile tunable, a pipeline knob) | `docs/api-conventions.md` §3d — reject out of range with a 422 naming the bound and its consequence, never accept-then-rewrite; validate against the constants the renderer reads, keep the render-time clamp for pre-existing rows, and let an UNCHANGED legacy value through on update. Then `api.ffmpeg-profile-numeric-bounds` |
|
||||
| Adding or changing a write path that takes a top-level LIST of FK ids (`graphicsElementIds`, `watermarkIds`) | `docs/api-conventions.md` §3b — bound the RAW list through `Validators.IdsMustExist` before `Distinct`, reject an unknown id with a 422 that names the request field, gate only the EXISTENCE half on whatever makes the apply path discard the list, and translate a lost race by re-running the handler's whole `Validate` on a fresh context. Then `api.top-level-id-list-validation`; the repo-wide rollout is #917 |
|
||||
| Testing a surface gated by config / an env var / a credential | `docs/decisions/records/testing/deny-path-at-production-config-value.md` — cover the setting absent, at its production value, and each opt-out, and assert the DENY branch |
|
||||
| Touching a full-replace write path or a hand-built request object | `docs/decisions/records/testing/full-replace-asserts-field-list.md` — derive the field list from the DTO and assert set equality; reconcile by id where child state exists. In the SPA the same rule is enforced by the type system: `docs/spa-conventions.md` §4b — build the body as `Complete<T>`, annotating BOTH the wrapper parameter and every construction site |
|
||||
| Writing or editing any doc, or answering a review finding in prose | `docs/decisions/records/docs/no-session-narrative.md` — the doc records the END STATE; the path to it goes in the commit message. Apply the who-benefits test, and read the carve-out before you cut (dated measurements, stated snapshot boundaries and tested-and-rejected results stay) |
|
||||
@@ -120,6 +122,8 @@ bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "K
|
||||
that file's standing kickoff for the two concurrent tracks (orientation ‖ selection). ersatztv#237
|
||||
is a closed, archival historical tracker (superseded by `startup.parallel-orientation` in
|
||||
`docs/decisions.md`) — not a live pointer.
|
||||
- **`docs/handoffs/orchestration.md`** — mechanics of an orchestrated session: roles, isolation, landing a branch through the gate (`process.orchestrated-session`), resume, incidents. Workflow scripts: `.claude/workflows/ersatztv-{pick-next,issue-build,resume-branch}.js`.
|
||||
- **`docs/handoffs/orchestrator-prompt.md`** — the standing prompt that starts an orchestrated session; the single-issue kickoff stays in `chicorytv-issue-queue.md`.
|
||||
- **`docs/defect-shapes-773.md`** — root-cause analysis of the recurring defect shapes across the
|
||||
whole closed-issue corpus (#773): the measured class ranking, the four families they consolidate
|
||||
into, the cheapest mechanical detector per class, the classes where **no** detector is plausible,
|
||||
|
||||
+57
-6
@@ -302,6 +302,32 @@ handler's validation when a lookup fails, so the controller-side mapping falls o
|
||||
caller) → surface as 422 instead of silently returning a shorter list.
|
||||
- Unbounded `int`/`TimeSpan` inputs from the request → clamp or validate, per the Logs pagination
|
||||
pattern above.
|
||||
- **A top-level LIST of FK ids is an unbounded input too** — the body-size cap is a transport limit,
|
||||
not a collection limit, and millions of compact integers fit under it. Bound the list with
|
||||
`Validators.IdsMustExist` (#568), which counts the **raw** list before `Distinct` and before any
|
||||
database work — deduplication is not what the request costs to parse and materialize — and caps how
|
||||
many rejected ids the 422 echoes back, so an oversized request does not earn an oversized response.
|
||||
The count is the REQUEST's bound, not the apply path's: where another field of the same body (a
|
||||
deco's `DecoMode`) makes the reconcile discard the list, the mode gates the EXISTENCE half only —
|
||||
a discarded list was still parsed and materialized out of the body. Pass that predicate to
|
||||
`IdsMustExist` as `idsAreConsumed` rather than short-circuiting the call, which takes the cap with
|
||||
it. The rollout to the other FK-id validators on these DTOs is #917; the rule and its residuals are
|
||||
`api.top-level-id-list-validation`.
|
||||
- **Name the request field in the validation message**, `[GraphicsElementIds] …`, using the same
|
||||
`[{GetMemberName(expression)}]` prefix `Validators.NotEmpty`/`NotLongerThan`/`AtLeast` already
|
||||
produce. A full-replace DTO carries several id lists, and `Graphics element(s) do not exist: 42`
|
||||
does not say which one to fix.
|
||||
- **Validation and the write are two statements, so an FK the validator confirmed can be gone by
|
||||
`SaveChangesAsync`** — a background job deleting the principal row (e.g. `RefreshGraphicsElements`
|
||||
removing elements whose template file is gone) restores the very 500 the validator was added to
|
||||
prevent. A transaction does not close that window: neither provider locks the rows the validator
|
||||
merely READ. Catch `DbUpdateException` around the save, re-ask the question on a **fresh** context
|
||||
(the failed one still tracks the changes it could not write), and return the validator's own 422 if
|
||||
an id has since gone — re-throwing anything else, so a real fault is not reported as a client error
|
||||
(#568). **Re-run the handler's whole `Validate`, never a named subset of its fields**: every FK the
|
||||
save writes loses the same race, and a recovery path that enumerates the fields it knows about
|
||||
silently omits the next one the DTO gains. Re-running the validator set covers a check added later
|
||||
by construction.
|
||||
- **Dereferencing a request `string` (e.g. `request.Name.Length`) is a latent 500** — request DTOs
|
||||
carry no `#nullable` context (§2), so a `string Name` binds `null` from `name: null`/an omitted field
|
||||
and there is no implicit `[Required]`; a raw `.Length`/`.Trim()` throws `NullReferenceException` → an
|
||||
@@ -641,7 +667,11 @@ Runtime `/api/*` JSON is serialized by **Newtonsoft** (`AddNewtonsoftJson` in `S
|
||||
`ErsatzTV/Serialization/CustomContractResolver.cs` → `CustomNamingStrategy` (camelCase **plus** a
|
||||
special case mapping any `FFmpegProfileId` member to `"ffmpegProfileId"`, and honoring any
|
||||
`[JsonProperty("...")]` attribute, e.g. `ChannelResponseModel.FFmpegProfile` →
|
||||
`[JsonProperty("ffmpegProfile")]`). The OpenAPI document, however, is generated from
|
||||
`[JsonProperty("ffmpegProfile")]`). The settings themselves live in
|
||||
`ErsatzTV/Serialization/ApiJsonSettings.cs`, which `Startup` applies — call it from a test that needs to
|
||||
serialize or bind the way the runtime does rather than restating the settings, which drifts silently
|
||||
(`OpenApiSerializerContractTests`, `ScriptedScheduleControllerTests`). The OpenAPI document, however, is
|
||||
generated from
|
||||
**System.Text.Json** metadata, whose camelCase can differ (it emitted `fFmpegProfileId` /
|
||||
`fFmpegProfile`). That drift silently gave the SPA the wrong key to read (issue #198).
|
||||
|
||||
@@ -931,11 +961,32 @@ for items that have no group, so the SPA can render an "ungrouped" bucket
|
||||
concept elsewhere, this is the established pattern to follow — but be aware it means `Id` is not a
|
||||
reliable real-entity id for those synthetic rows.
|
||||
|
||||
**Channel graphics (issue #74)**: `ChannelDetailResponseModel`/`UpdateChannelRequest` carry
|
||||
`graphicsElementIds` (the channel's attached `GraphicsElement` ids, reconciled add/remove on PUT via
|
||||
`Channel.ChannelGraphicsElements`), and `GraphicsElementResponseModel` exposes a server-derived
|
||||
`builtIn` (`Path.GetFileName(element.Path) == GraphicsElementDefaults.OnNowNextFileName`) — never
|
||||
client-settable.
|
||||
**Channel graphics (issue #74, hardened #568)**: `ChannelDetailResponseModel`/`UpdateChannelRequest`
|
||||
carry `graphicsElementIds` (the channel's attached `GraphicsElement` ids, reconciled add/remove on PUT
|
||||
via `Channel.ChannelGraphicsElements`); `UpdateChannelHandler.Validate` rejects any id not present in
|
||||
`GraphicsElements` with 422 (previously an unhandled 500 from the FK constraint — #568), matching
|
||||
every other field on this full-replace DTO. The identical shape existed on `PUT /api/v1/decos/{id}`
|
||||
(`ReplaceDecoRequest.graphicsElementIds`/`watermarkIds`, also top-level fields, not the
|
||||
`deep-FK-in-a-nested-list` carve-out of §3b above) and is hardened the same way by
|
||||
`UpdateDecoHandler.Validate` (`GraphicsElementIdsMustExist`/`WatermarkIdsMustExist`, #568). Each
|
||||
deco validator passes the same `Override`/`Merge` mode predicate that makes the apply path consume
|
||||
its id list — under `Inherit`/`Disable` the reconcile clears the join and ignores the ids, so
|
||||
validating them there would 422 a request over ids it was about to discard. The SPA sends both id
|
||||
lists whatever the mode selector says, so that shape arrives from the real editor. The predicate is
|
||||
an argument (`idsAreConsumed`) rather than a short-circuit around the call, because it gates the
|
||||
existence half alone and the raw-count cap applies under every mode.
|
||||
All three id lists go through one shared validator, `Validators.IdsMustExist`, which is where the
|
||||
count cap (`MaximumIdListCount`), the request field named in the message and the cap on echoed ids
|
||||
are written once rather than three times. Both handlers translate a lost race the same way, by
|
||||
re-running their own `Validate` on a fresh context after a `DbUpdateException` — so every FK on the
|
||||
DTO is covered, not just the id lists this issue was about.
|
||||
`GraphicsElementResponseModel` exposes a server-derived `builtIn`, computed by
|
||||
`GetAllGraphicsElementsForApiHandler` as
|
||||
`GraphicsElementDefaults.IsOnNowNext(element.Path, element.Kind)` — ordinal equality against the full
|
||||
seeded path AND `Kind == Text`, not the bare filename, which was folder-agnostic (#568) — never
|
||||
client-settable. `Kind` is inside that predicate rather than a filter each caller adds: the seeder
|
||||
requires it, and an API that did not would report a wrong-kind row at the seeded path as
|
||||
`builtIn:true` while the seeder refused to treat it as the built-in element (#568).
|
||||
|
||||
## 9. Authentication — session-or-key posture (fail-closed)
|
||||
|
||||
|
||||
+118
-31
@@ -101,6 +101,19 @@ commit in the range (see the `decisions.md` header) — routine lifecycle metada
|
||||
|
||||
1. Confirm `main` CI is green; run the full local gate plus `dotnet list package --vulnerable
|
||||
--include-transitive`; then push a `vYY.N.P` tag on that exact `main` commit.
|
||||
|
||||
> **`v*` tags are protected — push as `timothy`.** Since ersatztv#885 the repo carries one
|
||||
> tag-protection rule (`name_pattern: v*`, `whitelist_usernames: ["timothy"]`,
|
||||
> `release.tag-protection-v-star`), because a `v*` push builds and publishes `:prod`. A push from
|
||||
> any other account is expected to be refused — configured, not exercised, since only the
|
||||
> `timothy` credential is available here. **The positive half is UNVERIFIED too**: the rule was
|
||||
> applied 2026-09-04 and only its negative half was measured (a non-`v*` tag still pushes), because
|
||||
> pushing a `v*` tag outside a real cut would publish `:prod`. This cut is its first real
|
||||
> exercise. If the tag push is refused, unblock with
|
||||
> `DELETE /api/v1/repos/timothy/ersatztv/tag_protections/1`, push, then re-`POST` the rule
|
||||
> (prefer a `PATCH` adding the account if the cut has moved to a different operator, so the
|
||||
> protection is never left off) — and record the outcome on `release.tag-protection-v-star`,
|
||||
> which is waiting for it.
|
||||
2. Wait for tag CI to build `:prod` + the immutable `:<version>` + `:<sha>` images. Run
|
||||
`scripts/security-scan.sh` on jazz against **the immutable `:<version>` image**, not a
|
||||
moving tag, and triage every ZAP/semgrep finding.
|
||||
@@ -170,9 +183,15 @@ grants `releases: write`, which nothing here uses. The two read units are not sp
|
||||
`/issues/{n}/timeline` (the ersatztv#706 retarget fence) and, through
|
||||
`scripts/pr-changed-files.sh`, `/pulls/{n}` and `/pulls/{n}/files` (the ersatztv#698 exemption path).
|
||||
|
||||
No workflow needs a `packages:` unit. All five `container:` blocks in `docker-build.yml` carry
|
||||
explicit `credentials:` using the scoped `REGISTRY_*` PAT, so no job pulls the toolchain image with
|
||||
the injected token.
|
||||
No workflow needs a `packages:` unit. Since ersatztv#885 the five `container:` blocks in
|
||||
`docker-build.yml` carry no `credentials:` at all: they run on the `pull_request` route, so they may
|
||||
hold no stored secret, and the runner pulls the toolchain image ANONYMOUSLY — this registry issues a
|
||||
pull token for a public package with no credential (measured at the HTTP level 2026-09-04, and at the
|
||||
daemon level 2026-09-05 — a `docker pull` of the pinned tag with an empty docker config succeeds on
|
||||
the runner host, which is the same daemon that creates the job containers; see
|
||||
`ci.pr-route-carries-no-stored-credential`, whose `mechanics:` names both measurements and the two
|
||||
things neither exercises). So no job pulls that image with the injected
|
||||
token, and none pulls it with the `REGISTRY_*` PAT either.
|
||||
|
||||
### The binding was measured, not assumed (2026-08-27, Gitea 1.27.1)
|
||||
|
||||
@@ -736,7 +755,11 @@ the image build.
|
||||
for `192.168.1.95:3000` — **BuildKit does not inherit the host daemon's
|
||||
`insecure-registries`**, so without this, cache/base-image/push over the HTTP
|
||||
registry fails (`http: server gave HTTP response to HTTPS client`).
|
||||
3. `docker/login-action` with repo secrets `REGISTRY_USER` / `REGISTRY_PASSWORD`.
|
||||
3. `docker/login-action` with repo secrets `REGISTRY_USER` / `REGISTRY_PASSWORD`. **`build` is the
|
||||
ONLY job in this workflow that names them** (ersatztv#885,
|
||||
`ci.pr-route-carries-no-stored-credential`): it is gated `if: github.event_name != 'pull_request'`,
|
||||
and every job that IS on the PR route now reads the registry and the status API anonymously,
|
||||
because a `pull_request` run executes head-supplied YAML.
|
||||
**`REGISTRY_PASSWORD` is a scoped PAT (`write:package` + `read:repository`), not an account
|
||||
password** — deliberately, so head-resolved PR code cannot use it to forge a commit status
|
||||
(`ci.actions-credential-scoping`, ersatztv#697). If a job ever fails with `token does not have at
|
||||
@@ -901,8 +924,11 @@ the docs-only detect), and every heavy step gains an added `&& steps.revalidate.
|
||||
squash, rebase, fast-forward, or a direct push have no `HEAD^2`, so they run);
|
||||
- `git rev-parse HEAD^{tree}` equals `HEAD^2^{tree}` — main did not advance since the PR's last run,
|
||||
a byte-identical tree;
|
||||
- `HEAD^2` has a **green Gitea combined commit status**, queried via the API with
|
||||
`ETV_STATUS_AUTH`. Trusting the aggregate `.state` is sound: a `skipped` context does **not** drag
|
||||
- `HEAD^2` has a **green Gitea combined commit status**, queried via the API **anonymously**
|
||||
(ersatztv#885: the three jobs that call `scripts/ci-detect-already-validated.sh` also run on the
|
||||
`pull_request` route, so they hold no stored secret; `timothy/ersatztv` is public and answers the
|
||||
combined-status GET without a credential — measured 2026-09-04). Trusting the aggregate `.state`
|
||||
is sound: a `skipped` context does **not** drag
|
||||
the combined state below `success` (verified live against this instance — a real merge commit with
|
||||
four `skipped` PR-only contexts still reported `.state == success`), and the two required jobs
|
||||
never report `skipped` (they always run and report a real `success`/`failure`), so
|
||||
@@ -1148,8 +1174,10 @@ one case that's easy to forget and easy to detect: a PR that touches a SPA scree
|
||||
`::warning::` annotation.
|
||||
|
||||
**2. The session-narrative reminder** (ersatztv#784) runs `scripts/check-doc-narrative.py --diff`
|
||||
over the lines this PR **adds** to `docs/**/*.md` (minus `docs/decisions/**`, exempt wholesale) and
|
||||
root-level `*.md`, flagging text that narrates the document's own revision history —
|
||||
over the lines this PR **adds** to `docs/**/*.md` (minus `docs/decisions/**`, exempt wholesale),
|
||||
root-level `*.md`, and — since ersatztv#876 — every tracked file under `.claude/`, `.gitea/`, `.husky/`
|
||||
and `scripts/` regardless of extension (minus the detector and its own test, whose fixtures are the
|
||||
phrasings), flagging text that narrates the artifact's own revision history —
|
||||
`docs.no-session-narrative`. It is advisory **by design and permanently**: a narrative detector is a
|
||||
string predicate over prose, the class `docs/defect-shapes-773.md` §4 argues must never be
|
||||
load-bearing, so the script exits 0 on every path including a bad argument or an unresolvable base
|
||||
@@ -1430,8 +1458,14 @@ 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.
|
||||
they are fail-open and `--no-verify` bypasses them. Tag pushes go through a separate mechanism and
|
||||
are meant to stay unaffected for the release operator: since ersatztv#885 `tag_protections` carries
|
||||
one rule, `v*` whitelisted to `timothy` (`release.tag-protection-v-star`), which is intended to leave
|
||||
the release cut in "Cutting a release" working unchanged while refusing a `v*` tag from the `renovate`
|
||||
bot — a push that would otherwise have published a `:prod` image. Both halves of that are EXPECTED,
|
||||
UNVERIFIED: only the `timothy` credential exists here, so neither a real release cut nor a refused bot
|
||||
push has been exercised. `release.tag-protection-v-star` carries the same caveat and waits on the
|
||||
next cut.
|
||||
|
||||
**A fourth arm: guard-scope freshness (ersatztv#787).** `scripts/tests/test_ci_dropped_step_guard.py`
|
||||
decides which CI jobs must carry per-step execution markers by DERIVING that scope from
|
||||
@@ -1747,6 +1781,16 @@ arranging two real merges, or adding a throwaway trigger; both cost more than th
|
||||
because nothing branches on it. The `pull_request_target` half the fence actually relies on was **not**
|
||||
re-measured either and is 1.25.4-dated too.
|
||||
|
||||
The two facts this paragraph rests on — that no workflow triggers on a push to a non-`main` branch,
|
||||
and that `main` refuses direct pushes — were **re-checked on 2026-09-02 (ersatztv#869)** and both still
|
||||
hold. Note what that does and does not cover: it re-checks the REASON, not the claims. The
|
||||
`push`-supersession and `pull_request_target` measurements above remain 1.25.4-dated and were not
|
||||
re-run by #869 either. Re-checking the reason matters on its own, because a reason decays exactly like
|
||||
the claim it excuses. One limit is worth stating so this is not over-read: the enumeration covers
|
||||
`push:` triggers only. Four of the six workflows carry `workflow_dispatch:`, so a disposable run can
|
||||
still be raised on demand — what is gone is the cheap scratch-BRANCH push probe, not every route to a
|
||||
throwaway run.
|
||||
|
||||
Separately, after posting **any** status the job re-reads the per-POST status history and, if a human
|
||||
`Review-verdict:` row appeared during the write window, overwrites its own status with `pending` and
|
||||
logs an error. It ran only for a `success` write until ersatztv#849, on the claim that a `pending`
|
||||
@@ -1818,6 +1862,18 @@ validate at most 950 rows, since the twentieth must be the empty terminator. The
|
||||
per endpoint and they differ: `/statuses/{sha}` returns `[]`, `/issues/{n}/timeline` a bare `null`,
|
||||
`/commits/{sha}/status` an object with `statuses: null`.
|
||||
|
||||
**The two walks terminate by OPPOSITE rules, and that asymmetry is deliberate** (ersatztv#870, settled
|
||||
by ersatztv#893 on 2026-09-02). `count_pr_mutations` reads the timeline to its cap and trusts the
|
||||
counts only when the LAST page came back empty; `page_statuses` still returns on its FIRST empty page.
|
||||
They differ because the ENDPOINTS differ, established from the v1.27.1 source: `getCommitStatuses`
|
||||
serializes every row the paged query returns — an unconditional `append` loop, the only filter being a
|
||||
SQL `WHERE` the database evaluates in the same query as the `LIMIT`/`OFFSET` — so a serialized page is
|
||||
exactly as long as its database page, and an empty page really is the end. `ListIssueCommentsAndTimeline` instead drops
|
||||
`CommentTypeCode` rows AFTER paging, which is what makes a fully filtered page byte-identical to the
|
||||
end of the list. The same source explains the header: `/statuses/{sha}` sets `X-Total-Count` from a SQL
|
||||
COUNT, so it is a true total (page 1 of 50 on a head reporting 63, measured 2026-09-02), where the
|
||||
timeline reports its post-filter page length. **Do not "tidy" the two walks into agreement.**
|
||||
|
||||
**The page-2 probe is gone.** ersatztv#751 treated "there are rows I did not read" as "assume raced".
|
||||
That fired on Renovate PR #761: a head that grew past one page over ordinary CI re-runs had its
|
||||
exemption repaired away, with an `::error::` asserting a human verdict was overwritten when the head
|
||||
@@ -1895,8 +1951,10 @@ as establishing that the gate cannot be forged (see the residual below, and ersa
|
||||
`docs/decisions/records/ci/gate-trigger-base-resolved.md`. **This closes the rewrite route through
|
||||
this workflow, not the class:** `docker-build.yml` is also head-resolved and must stay on
|
||||
`pull_request` because it builds the PR's code, so it got the read-only status identity instead —
|
||||
its `ETV_STATUS_AUTH` is now a PAT scoped `write:package` + `read:repository`, which the status
|
||||
endpoint refuses (`ci.actions-credential-scoping`, ersatztv#697). The inventory was never that one
|
||||
the `ETV_STATUS_AUTH` it passed was a PAT scoped `write:package` + `read:repository`, which the
|
||||
status endpoint refuses (`ci.actions-credential-scoping`, ersatztv#697). Since ersatztv#885 it
|
||||
passes no credential there at all: the status read is anonymous, so the PR route materialises
|
||||
nothing to refuse. The inventory was never that one
|
||||
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
|
||||
@@ -2249,16 +2307,27 @@ documented API paths mention "environment", and the config file the running serv
|
||||
restricting dispatch would close nothing: the `pull_request:` row above is cheaper than any
|
||||
dispatch, because it runs **attacker-authored YAML**, which can name any secret in the repo store —
|
||||
not only the ones the committed workflows reference. That is what makes it cover `renovate.yml`'s
|
||||
`RENOVATE_TOKEN` without dispatching `renovate.yml` at all. Concretely today, six jobs in
|
||||
`docker-build.yml` hold `REGISTRY_PASSWORD` and run on the PR route (`toolchain-preflight`, `test`,
|
||||
`migrations`, `functional-e2e`, `api-docs`, `format`), two of them required contexts — carry that as
|
||||
*every job on the PR route that names `secrets.REGISTRY_PASSWORD`*, not as the list, and not as
|
||||
"every `container:` job" either: `toolchain-preflight` is container-free and would fall out of that
|
||||
predicate. "Push a branch, open a PR" therefore reaches the credential with **no act outside the
|
||||
ordinary contribution flow**, where a dispatch costs one. (`dependency-scan.yml` references no
|
||||
secrets at all.) Full reasoning and the probe: `ci.workflow-dispatch-ref-unrestricted`. The live
|
||||
residuals are that PR route and the `v*` tag push, tracked in #885 — along with `tag_protections`,
|
||||
which is still empty.
|
||||
`RENOVATE_TOKEN` without dispatching `renovate.yml` at all. (`dependency-scan.yml` references no
|
||||
secrets at all.) Full reasoning and the probe: `ci.workflow-dispatch-ref-unrestricted`.
|
||||
|
||||
Six jobs in `docker-build.yml` used to hold `REGISTRY_PASSWORD` on that route, two of them required
|
||||
contexts. Since ersatztv#885 **no job on the `pull_request` route names any stored secret**
|
||||
(`ci.pr-route-carries-no-stored-credential`): the toolchain image is pulled through the registry's
|
||||
anonymous bearer-token flow and the commit-status API is read unauthenticated, leaving the PAT to
|
||||
`build`, which the route cannot reach. The invariant is held by
|
||||
`scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`,
|
||||
which derives the population from the git index by the predicate *every job of a
|
||||
`pull_request`-triggered workflow that names a `secrets.*`* — never a list, and never "every
|
||||
`container:` job" either, which names five of the six because `toolchain-preflight` is
|
||||
container-free. The `v*` tag-push row is closed against the bot by `release.tag-protection-v-star`.
|
||||
|
||||
What that does **not** close, and must not be read as closing: `REGISTRY_PASSWORD` is still in the
|
||||
repo's Actions store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or
|
||||
`SERVERMGMT_DEPLOY_KEY` in a job of its own. "Push a branch, open a PR" still reaches every secret in
|
||||
the store with **no act outside the ordinary contribution flow**. Bounding the store needs
|
||||
per-environment secret scoping, which Gitea 1.27.1 does not have. What is removed is the routine
|
||||
materialisation of a write-capable credential into six PR-run environments — blast radius, not the
|
||||
route.
|
||||
|
||||
**A `v*` tag push does not fire `ci-image.yml`.** There is no `tags:` key, and a `branches:` filter
|
||||
is compared against a branch ref — so a release cut no longer republishes the toolchain image as a
|
||||
@@ -2307,7 +2376,14 @@ through a rebase.
|
||||
> recovery — are #854, deferred on exactly these grounds.
|
||||
|
||||
**A change that lives only in `ci-image.yml` publishes nothing.** That file is no longer one of its
|
||||
own trigger paths, nor one of `ci-image-pin`'s `expected` paths. That pairing is a **decided
|
||||
own trigger paths, nor one of `ci-image-pin`'s `expected` paths. Those two lists must otherwise name
|
||||
the same sources, and `scripts/tests/test_ci_image_paths_pin_agreement.py` holds them to it — it
|
||||
derives both from the workflow documents and compares them for set equality, so editing one alone
|
||||
reddens `script-tests`. That job is not a required context, so the red does not block server-side; it
|
||||
denies the merge-consent auto-grant through the combined status. **Read that guard before taking the
|
||||
reversal described next**: it models a `<dir>/**`-against-`<dir>` pair only, so putting
|
||||
`ci-image.yml` back into both lists is a file-against-file pair it refuses, and would need a file arm
|
||||
added there first (ersatztv#855). That pairing is a **decided
|
||||
tradeoff, not a necessity** — keeping the file in `expected` still works, since the branch dispatch
|
||||
can publish the ci-image.yml commit itself and the pin then matches. What it costs is the reason it
|
||||
went: that route charges a full ~2 GB publish and a five-pin bump for *every* edit to the file, a
|
||||
@@ -2336,7 +2412,7 @@ sha tag once 15 newer versions of the package exist. `ci-image.yml` publishes a
|
||||
and on every push to `main` touching `docker/ci/**` (plus every branch dispatch), while the pin only
|
||||
moves when a human bumps it — so a pin ages toward eviction on its own. That is what happened between 2026-08-11 and
|
||||
2026-08-13 (ersatztv#772): the tag vanished, and every `container:` job — **both required contexts
|
||||
included** — died after 1–2s with
|
||||
included** — died at image pull, before running a step, with
|
||||
|
||||
```
|
||||
Error response from daemon: failed to resolve reference ".../ersatztv-ci:<pin>": not found
|
||||
@@ -2382,16 +2458,27 @@ because they send you to different places:
|
||||
| HTTP 200 with a manifest body | green | resolves |
|
||||
| HTTP 404 | **red** | `IS GONE` — rebuild the tag (recovery above) |
|
||||
| HTTP 200, body is not a manifest | **red** | something is answering for the registry (proxy, login page) |
|
||||
| 401 / 403 | **red** | the credentials were rejected — fix the secrets |
|
||||
| 401 / 403 that survives a bearer the run actually obtained | **red** | the registry `refused an ANONYMOUS read ... even after a Bearer token was obtained` — about the PACKAGE: check that the `ersatztv-ci` package is still PUBLIC. It is linked to no repository (measured 2026-09-05: every version reports `"repository": null`), so this is the package's own visibility and NOT `timothy/ersatztv`'s |
|
||||
| 401 whose token leg ANSWERED and yielded no bearer — no `Www-Authenticate` at all, a challenge naming no realm, or a token endpoint answering with no token | **red**, asked once | `could NOT OBTAIN an anonymous pull token ... after 1 token-leg attempt(s)` — about the registry's TOKEN ENDPOINT, not the package's visibility. The endpoint said something, so asking again cannot change it |
|
||||
| 401 whose token endpoint could not be REACHED, or answered 5xx | **red** after `ETV_CI_ATTEMPTS` token-leg tries | the same message, naming the attempts it actually made. An endpoint that said nothing is the same transport blip a flaky manifest read gets to survive — the two legs of one read must not have opposite flake tolerances when a red here denies a merge |
|
||||
| 403 on the FIRST read, before any token leg — `probe` enters it on a `401` only | **red** | `refused an ANONYMOUS read ... and NO TOKEN WAS EVER REQUESTED` — it reports what the RUN did (never followed this answer as a challenge), not what the registry sent. About ACCESS to the registry: visibility, or something answering for it |
|
||||
| anything else (5xx, unreachable, no `curl`) | **red** after `ETV_CI_ATTEMPTS` tries | `could NOT VERIFY` — check the registry's health, NOT the pin |
|
||||
| `ETV_REGISTRY_AUTH` unset, malformed, or either half empty | **red**, before any query | an absent secret interpolates to `":"`, which is not a credential |
|
||||
|
||||
The last two rows are the ones worth defending, because warning on them and exiting 0 is the natural
|
||||
way to write this check and it is wrong: a missing `curl`, a moved registry and a DNS change all land
|
||||
there, and a green-with-a-warning job is indistinguishable from a healthy pin forever after. The
|
||||
**The refusal rows say only what actually ran**, and that is the point of wording them apart
|
||||
rather than a nicety: a message naming a step the run skipped is evidence for a diagnosis nobody
|
||||
performed. So the arm branches on `token` first — a bearer was obtained — then on whether the token
|
||||
leg was attempted at all, and only then reports the never-asked case; and the token-leg message
|
||||
carries its own attempt count rather than leaving the reader to infer one from the retry policy.
|
||||
|
||||
Those refusal rows and the `could NOT VERIFY` row are the ones worth defending, because warning
|
||||
on them and exiting 0 is the natural way to write this check and it is wrong: a missing `curl`, a
|
||||
moved registry and a DNS change all land in the last of them, and a green-with-a-warning job is
|
||||
indistinguishable from a healthy pin forever after. The
|
||||
unknown arm retries first (`ETV_CI_ATTEMPTS`, default 3, `ETV_CI_RETRY_SECONDS` apart) so an ordinary
|
||||
registry blip does not redden a PR — that pause is what makes failing on unknown affordable, and
|
||||
shortening it silently trades this guard for flake.
|
||||
shortening it silently trades this guard for flake. **An unreachable token endpoint is in that
|
||||
class, not in the refusal class**, and is retried on the same budget: the split is by what the
|
||||
endpoint SAID, never by which leg of the read it happened on.
|
||||
|
||||
**It is not a `needs:` of anything, but it is not consequence-free either.** The merge-consent hook
|
||||
reads the PR's **combined** status and denies on a non-`success` combined state (a `skipped` context
|
||||
@@ -2433,7 +2520,7 @@ check and takes no arguments. Run it from the CURRENT checkout, not the pin work
|
||||
`$repo` is kept above:
|
||||
|
||||
```bash
|
||||
ETV_REGISTRY_AUTH=user:pass scripts/ci-toolchain-image-resolves.sh
|
||||
scripts/ci-toolchain-image-resolves.sh
|
||||
```
|
||||
|
||||
**What this rebuild does and does not restore.** It restores a *working* toolchain at that tag, built
|
||||
|
||||
+2
-2
@@ -99,7 +99,6 @@ leaving the shared tree dirty is the one outcome that would make this script a n
|
||||
**Negative control (inherited from #231/#250).** A dedicated test hammers unbalanced releases on an empty pool while reader threads sample the count; none may ever observe a value below zero. Reinstating the pre-#539 decrement-first body makes it fail (`sawNegative > 0` — the readers catch the transient `−1`); verified. As with the #536 tests, break the primitive by reverting the real body, **not** `if (true)` (CS0219 under warnings-as-errors leaves `--no-build` running a stale, still-fixed dll).
|
||||
**SPA is id-fields-first; the API is complete ahead of the UI.** The Channel Builder + Auto-Tune DetailPanel re-add a real "None" option to the five id selects (watermark + fillers) — the pickers #89 had degraded to "Inherit"-only — routed through a `CLEAR` overrides sentinel folded into `advanced.clear` at request-build time (`applyOverridesToRequest`, so the sentinel never leaks as a field value). The three string clear-fields are covered by the backend enum for machine clients (MCP) but the SPA text inputs keep "empty = inherit"; adding a tri-state to those inputs is deferred, not blocked. This is the deliberate "REST API is a real audience" posture (`rest-api-purpose-mcp-and-new-ui`).
|
||||
`security.iptv-browser-token`.
|
||||
external-process pipeline remains #563's.
|
||||
|
||||
picker; TZ-independence holds only for divisors of 60. See #77 (prior art) and #392.
|
||||
|
||||
@@ -202,7 +201,7 @@ another doc or an old issue comment should land here and then follow the link.
|
||||
- 2026-07-21 — `WorkAheadSlots.Release()` clamps before decrementing and reports unbalance in-band (#539) — [`ffmpeg.work-ahead-slot-release-never-negative`](decisions/records/ffmpeg/work-ahead-slot-release-never-negative.md)
|
||||
- 2026-07-21 — `from-lineup` advanced overrides express "clear to none" via a typed `clear` enum list (#135) — [`api.from-lineup-clear-to-none`](decisions/records/api/from-lineup-clear-to-none.md)
|
||||
- 2026-07-22 — Channel-level graphics-element attachment + seeded On Now/Next text element (#74) — [`graphics.channel-level-attachment`](decisions/records/graphics/channel-level-attachment.md)
|
||||
- 2026-07-22 — Sequential (YAML) playout gets a golden; Scripted is excluded from the golden net by construction (#381) — [`testing.scripted-playout-golden-deferred`](decisions/records/testing/scripted-playout-golden-deferred.md)
|
||||
- 2026-07-22 — Sequential (YAML) playout gets a golden; Scripted is excluded from the golden net by construction (#381) — [`testing.scripted-playout-golden-deferred`](decisions/archive/testing/scripted-playout-golden-deferred.md) (superseded by `testing.scripted-engine-in-process-net`)
|
||||
- 2026-07-22 — per-schedule clock-boundary padding is a synthetic content-less Pad over the existing per-episode machinery (#392) — [`sched.clock-padding-schedule-toggle`](decisions/records/sched/clock-padding-schedule-toggle.md)
|
||||
- 2026-07-23 — Channel health = a server-derived `health` object on the channel DTOs, built-timeline detection (#415) — [`api.channel-health-object`](decisions/records/api/channel-health-object.md)
|
||||
- 2026-07-23 — Channel origin is immutable creation-provenance, stamped at insert, not a health signal (#414) — [`channel.origin-marker`](decisions/records/channel/origin-marker.md)
|
||||
@@ -216,3 +215,4 @@ another doc or an old issue comment should land here and then follow the link.
|
||||
- 2026-07-25 — The rationale-edit marker is a git trailer, not a substring anywhere in the commit range (#609) — [`ci.decisions-edit-trailer`](decisions/records/ci/decisions-edit-trailer.md)
|
||||
- 2026-07-25 — UI-E2E: headless Playwright flows in the existing `functional-e2e` job, browser baked into the CI image (#445) — [`ci.ui-e2e-harness`](decisions/records/ci/ui-e2e-harness.md)
|
||||
- 2026-07-26 — Facet-value typeahead restated: every artist source covered; the JSON-column source is paged by row position with no residual SQL predicate (#578) — [`api.search-field-values-sources`](decisions/records/api/search-field-values-sources.md)
|
||||
- 2026-09-05 — Scripted playout is covered in-process, engine plus controller adapter; the Cli.Wrap process and the HTTP transport are permanently out of the automated suite (#563) — [`testing.scripted-engine-in-process-net`](decisions/records/testing/scripted-engine-in-process-net.md)
|
||||
|
||||
File diff suppressed because one or more lines are too long
+4
-4
@@ -1,13 +1,13 @@
|
||||
---
|
||||
key: testing.scripted-playout-golden-deferred
|
||||
title: 2026-07-22 — Sequential (YAML) playout gets a golden; Scripted is excluded from the golden net by construction (#381)
|
||||
status: active
|
||||
status: superseded
|
||||
since: '2026-07-22'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'The `PlayoutBuildGoldenTests` in-memory golden net covers Sequential (YAML) as of #381. Scripted''s *end-to-end pipeline* is excluded — `ScriptedPlayoutBuilder` runs a user-authored external program that drives the engine over HTTP loopback, which the in-memory harness can''t pin — so that full-pipeline (integration) harness is deferred to #563. But the scheduling *behavior* those scripts drive lives entirely in the in-process `SchedulingEngine` (the `ScriptedScheduleController` is a 1:1 pass-through to it), which IS directly unit/golden-testable; the earlier "Scripted is un-golden-able by construction" framing overstated the constraint by conflating transport with engine. #395 extracts that shared switch to `ContentEnumeratorBuilder` and adds a direct regression net (`ContentEnumeratorBuilderTests`) over it.'
|
||||
superseded-by: testing.scripted-engine-in-process-net@2026-09-05
|
||||
rule: '(superseded) The `PlayoutBuildGoldenTests` in-memory golden net covers Sequential (YAML) as of #381. Scripted''s *end-to-end pipeline* is excluded — `ScriptedPlayoutBuilder` runs a user-authored external program that drives the engine over HTTP loopback, which the in-memory harness can''t pin — so that full-pipeline (integration) harness is deferred to #563. But the scheduling *behavior* those scripts drive lives entirely in the in-process `SchedulingEngine` (the `ScriptedScheduleController` is a 1:1 pass-through to it), which IS directly unit/golden-testable; the earlier "Scripted is un-golden-able by construction" framing overstated the constraint by conflating transport with engine. #395 extracts that shared switch to `ContentEnumeratorBuilder` and adds a direct regression net (`ContentEnumeratorBuilderTests`) over it.'
|
||||
signals: 'why is there no scripted golden; scripted transport vs engine; SchedulingEngine is in-process testable; Cli.Wrap external process is transport only; ScriptedScheduleController 1:1 pass-through; engine-level scripted regression net; EnumeratorForContent · paths: `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs`, `ErsatzTV.Core.Tests/Scheduling/Engine/SchedulingEngineTests.cs`, `ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs`, `ErsatzTV/Controllers/Api/ScriptedScheduleController.cs`, `ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs` · issues: #381, #163, #395, #563'
|
||||
mechanics: docs/testing.md → Golden-file nets
|
||||
mechanics: superseded by `testing.scripted-engine-in-process-net` (ersatztv#563), which stands up the deferred coverage in-process and corrects the "1:1 pass-through" description of `ScriptedScheduleController`
|
||||
---
|
||||
|
||||
**Sequential (YAML) is golden-able and TZ-independent.** `SequentialPlayoutBuilder` reads a YAML schedule
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
key: api.top-level-id-list-validation
|
||||
title: '2026-09-05 — A top-level list of FK ids on a full-replace write DTO is bounded, existence-checked, named in its own 422, and re-asked after a lost race (#568)'
|
||||
status: active
|
||||
since: '2026-09-05'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'A list of foreign-key ids that is a TOP-LEVEL field of a full-replace write DTO (`UpdateChannelRequest.graphicsElementIds`, `ReplaceDecoRequest.graphicsElementIds`/`watermarkIds`) is validated like every other FK field on that DTO, through the one shared primitive `Validators.IdsMustExist`. Four properties, in this order: (1) the RAW list length is capped at `Validators.MaximumIdListCount` before `Distinct` and before any database work, because deduplication is not what the request cost to parse and materialize; (2) an id with no matching row is a 422, never the FK constraint reaching `SaveChangesAsync` as an unhandled 500; (3) the message carries the request field name in brackets, `[GraphicsElementIds] ...`, the prefix `Validators.NotEmpty`/`NotLongerThan`/`AtLeast` already produce, and echoes at most ten missing ids plus a count; (4) where a SIBLING field of the same request makes the apply path discard the list — a deco `DecoMode` outside `Override`/`Merge` — that predicate is passed in as `idsAreConsumed` and gates the EXISTENCE half ONLY. It never gates the cap: a discarded list was parsed and materialized out of the request body all the same. Because validation and the write are two statements, a handler that writes any FK also catches `DbUpdateException` around the save and re-runs ITS OWN `Validate` on a FRESH context, returning the validator''s own 422 when a principal row has since gone and re-throwing otherwise. The whole validator set is re-asked, never a named subset of fields. This is NOT the deep-FK carve-out of api-conventions.md 3b, which stays: ids nested inside item-list request bodies are still not existence-checked at that depth.'
|
||||
signals: 'unknown graphicsElementIds returns 500 · unbounded client-supplied id array · which id list does this 422 mean · FK deleted between validation and SaveChangesAsync · deco mode gate skips the count cap · IdsMustExist · MaximumIdListCount · idsAreConsumed · ApplyUpdateRequestTranslatingLostRace · paths: `ErsatzTV.Application/Validators/IdListValidation.cs`, `ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs`, `ErsatzTV.Application/Scheduling/Commands/UpdateDecoHandler.cs`, `docs/api-conventions.md` 3b · issues: #568, #917, #74'
|
||||
mechanics: '`Validators.IdsMustExist<T>(input, expression, noun, idsAreConsumed, findExisting, ct)` in `ErsatzTV.Application/Validators/IdListValidation.cs`; the field name comes from `GetMemberName(expression)`, so it cannot drift from the DTO. Pinned by `UpdateChannelGraphicsElementsTests` and `UpdateDecoGraphicsElementsTests`; the clause mutations and their measured red sets are rows 35-47 of the table in `docs/graphics-elements.md`.'
|
||||
---
|
||||
|
||||
- **The cap belongs to the request, the existence check belongs to the apply path.** These are two
|
||||
different questions and the first attempt at the mode gate conflated them: the deco validators
|
||||
short-circuited the whole `IdsMustExist` call when `DecoMode` was `Inherit`/`Disable`, which was
|
||||
right about existence and wrong about the count. A validator may not reject an id the reconcile
|
||||
was going to throw away — the SPA sends both id lists whatever the mode selector says, so a draft
|
||||
holding a since-deleted element must still be able to save the deco back to `Inherit` — but the
|
||||
array was still bound, allocated and materialized before any of that was known. Hence
|
||||
`idsAreConsumed` as a parameter of the shared primitive rather than an `if` around the call site:
|
||||
the primitive decides what the flag may switch off, and no call site can widen it.
|
||||
|
||||
- **A recovery path that names its own fields is the defect it is recovering from.** Re-asking only
|
||||
the graphics-element question after a `DbUpdateException` left `WatermarkId`, `FFmpegProfileId`,
|
||||
`FallbackFillerId` and `MirrorSourceChannelId` — every one of them written by the same
|
||||
`SaveChangesAsync`, every one of them a real FK — surfacing the same unhandled 500 the catch exists
|
||||
to remove. Re-running the handler's own `Validate` is what makes the coverage derive from the
|
||||
validator set instead of from whoever last edited the catch. A transaction is not an alternative:
|
||||
neither provider locks rows the validator merely READ, so the concurrent delete commits either way.
|
||||
The context must be a fresh one — the failed context still tracks the changes it could not write,
|
||||
and would answer the existence query out of them.
|
||||
|
||||
- **Bounded, but only here — and deliberately.** Three validators on two DTOs carry this cap; the
|
||||
other FK validators on the same DTOs, and the `O(existing x desired)` reconcile loops the ids then
|
||||
feed, do not. That is not an oversight and it is not a finished rule: capping three sites and not
|
||||
the rest is a per-field constant, and #917 owns the repo-wide question (where the bound lives, how
|
||||
the limit is derived, and bounding the apply paths as well as the validators). Cite this record for
|
||||
the SHAPE a bounded id list takes; expect #917 to replace the mechanism.
|
||||
|
||||
- **`MaximumIdListCount` is a ceiling on abuse, not a product limit.** 512, against tables an
|
||||
operator curates by hand where a few dozen rows is a large install. Nothing reachable from the
|
||||
editor can approach it, which is what lets it be a hard rejection rather than a clamp.
|
||||
|
||||
- **A 422 that echoes every rejected id turns an oversized request into an oversized response.** Ten
|
||||
ids and a count: enough to fix the payload by hand, bounded by construction.
|
||||
@@ -5,9 +5,9 @@ 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. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. 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. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`''s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo''s checkouts now set it — 15 in #746 and `ci-image.yml`''s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation. The `workflow_dispatch` half of what survives is now settled rather than open: #853 probed Gitea 1.27.1 and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`) — there is no ref restriction and no protected-environment concept to gate a secret behind, and restricting dispatch would close nothing anyway, because `docker-build.yml`''s head-resolved `pull_request:` runs attacker-authored YAML, which reaches EVERY secret in the store and not merely the ones the committed workflows name. The PR route and the `v*` tag push, not dispatch, are the live residuals (#885).'
|
||||
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. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. 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. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`''s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo''s checkouts now set it — 15 in #746 and `ci-image.yml`''s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation. The `workflow_dispatch` half of what survives is now settled rather than open: #853 probed Gitea 1.27.1 and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`) — there is no ref restriction and no protected-environment concept to gate a secret behind, and restricting dispatch would close nothing anyway, because `docker-build.yml`''s head-resolved `pull_request:` runs attacker-authored YAML, which reaches EVERY secret in the store and not merely the ones the committed workflows name. The PR route and the `v*` tag push, not dispatch, were the live residuals; both were addressed in #885 — the tag push is whitelisted to the release operator (`release.tag-protection-v-star`) and NOTHING on the `pull_request` route names a stored secret any more — no job and no workflow scope (`ci.pr-route-carries-no-stored-credential`), so the `container:` pull, `ETV_REGISTRY_AUTH` and `ETV_STATUS_AUTH` are all gone from that route and `REGISTRY_PASSWORD` reaches only `build`. That bounds the blast radius; it does not close the route, because head-supplied YAML can still name any secret in the store.'
|
||||
signals: 'admin password in CI secrets, registry credential scope, ETV_STATUS_AUTH can write statuses, forge review-verdict/h10, head-resolved workflow holds credentials, persist-credentials on actions/checkout, credential left in .git/config, masked git fetch yields empty changed set, 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/pr-checks.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/ci-image.yml`, `.gitea/workflows/renovate.yml`, `scripts/ci-detect-already-validated.sh` · issues: #697, #672, #698, #742, #743, #746, #748, #835, #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.'
|
||||
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. RE-PROBED 2026-09-02 on Gitea 1.27.1 (#869) in a throwaway repo with a freshly minted PAT of the same two scopes: same 403 with the same message, plus a read control (same token GET -> 200) and a write control (admin POST -> 201); repo and token deleted, deletion confirmed. NOT COVERED BY EITHER PROBE (2026-08-05 or the #869 re-probe): the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea''s scope model (write implies read per category, re-read at tag `v1.27.1` 2026-09-02) — INFERRED. Note WHICH run exercises which, and that this CHANGED on 2026-09-04 (#885). The buildcache write and the base-image pull are confined to the `build` job, which carries `if: github.event_name != ''pull_request''`. The `container:` pull is not in `build` at all — `build` declares no `container:` — and it is the one a PR used to exercise with this credential; the five `container:` blocks now declare no `credentials:` and pull ANONYMOUSLY, so there is no credential left there to infer about (`ci.pr-route-carries-no-stored-credential`). No PR run therefore exercises this PAT at all, and the two credentialed uses are first exercised on the post-merge push to `main`, AFTER the merge gate has passed, so a wrong inference reddens main, not the PR.'
|
||||
---
|
||||
|
||||
**What was wrong.** `REGISTRY_USER`/`REGISTRY_PASSWORD` were the **admin account's** basic auth, and
|
||||
@@ -102,16 +102,50 @@ count is now **16 of 16 with no exemption**. That is what let #835 ship the guar
|
||||
exemption list at all: a one-entry list justified by a temporary condition elsewhere keeps passing
|
||||
after that condition expires, leaving the guard permanently blind to the file it was written for.
|
||||
|
||||
**Measured vs inferred.** Measured here: the `v1.25.4` scope enum (`access_token_scope.go`) has no
|
||||
**Measured vs inferred.** Measured here: the scope enum (`access_token_scope.go`) has no
|
||||
`status` entry; the `reqRepoWriter` gate (`routers/api/v1/api.go`); the probes in `mechanics`. Read from
|
||||
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.
|
||||
|
||||
**MEASURED 2026-08-27 on 1.27.1 (#748) — this block is NOT covered by the 1.25.4 caveat below.** It
|
||||
re-probes exactly ONE of that caveat's items, the `permissions:` question, and supersedes the
|
||||
"whether `permissions:` is honored here is UNPROBED" claim. It does NOT discharge the caveat: the
|
||||
scope enum, the `reqRepoWriter` gate and the `write:package` 403 are still 1.25.4-dated and
|
||||
un-re-run. Method: a matched pair of scratch-base probe PRs, identical except for one permission
|
||||
**RE-ESTABLISHED ON 1.27.1, 2026-09-02 (#869) — the three items the version caveat below NAMES.**
|
||||
It discharges those three and only those three. It does NOT discharge every dated statement in this
|
||||
record: "no container tag immutability in Gitea 1.25" (above) is still INFERRED and untouched, and
|
||||
the caveat is kept rather than deleted because it names what each item rests on.
|
||||
|
||||
- **Scope enum — no `status` scope.** Read at tag `v1.27.1`, `models/auth/access_token_scope.go` —
|
||||
the same path it occupies at `v1.25.4`, checked in both trees rather than assumed, because this
|
||||
record had only ever named the file and not its directory.
|
||||
Nine categories — ActivityPub, Admin, Misc, Notification, Organization, Package, Issue,
|
||||
Repository, User — and the string `status` does not occur in the file at all. `write:package`,
|
||||
`read:repository` and `write:repository` remain distinct scopes, which is what keeps the registry
|
||||
credential scopable off status-write at no cost.
|
||||
- **The `reqRepoWriter` gate.** Read at tag `v1.27.1`, `routers/api/v1/api.go`: the `/statuses`
|
||||
group registers `m.Combo("/{sha}").Get(repo.GetCommitStatuses).Post(reqToken(),
|
||||
reqRepoWriter(unit.TypeCode), bind(api.CreateStatusOption{}), repo.NewCommitStatus)`. Unchanged in
|
||||
substance from the 1.25.4 read: writing a commit status is still gated on repository-code WRITE,
|
||||
so any credential that can write the repository can still forge `review-verdict/h10`.
|
||||
- **The `write:package` 403 — re-probed live, with both controls.** A PAT scoped exactly
|
||||
`write:package,read:repository`, minted against the live 1.27.1 instance, `POST
|
||||
/repos/{o}/{r}/statuses/{sha}` -> **403**, message byte-for-byte the 1.25.4 one: `token does not
|
||||
have at least one of required scope(s), required=[write:repository], token
|
||||
scope=write:package,read:repository`. Two controls, because a bare 403 is equally consistent with a
|
||||
dead token or a dead route: the SAME token `GET`ting the same path -> **200** (the token is live and
|
||||
the read scope works), and an admin credential `POST`ing the same path -> **201** (the route accepts
|
||||
a POST). **Be precise about what the second control licenses** — it changes the credential, so scope
|
||||
and identity vary together and the PAIR alone cannot separate a SCOPE refusal from a
|
||||
unit-permission refusal. What actually discriminates is the 403 BODY, which names the scope it
|
||||
wanted and the scope it got; the admin 201 only rules out "the route rejects every POST". Cite the
|
||||
message as the evidence and the control as its backstop, not the other way round. Run in a
|
||||
throwaway private repo created for the probe; repo and token both deleted afterwards and the
|
||||
deletion confirmed (repo `GET` -> 404, token `DELETE` -> 204), so nothing was written to a real
|
||||
head.
|
||||
|
||||
**MEASURED 2026-08-27 on 1.27.1 (#748).** It re-probed exactly ONE of the old 1.25.4 caveat's items,
|
||||
the `permissions:` question, and supersedes the "whether `permissions:` is honored here is UNPROBED"
|
||||
claim. It did **not** by itself discharge that caveat — the scope enum, the `reqRepoWriter` gate and
|
||||
the `write:package` 403 were still 1.25.4-dated and un-re-run when this paragraph was written; #869
|
||||
re-established all three on 2026-09-02, which is what finally discharged it (see the block above and
|
||||
"Version history" below). Method: a matched pair of scratch-base probe PRs, identical except for one permission
|
||||
unit, the block carried **at job level on `set-verdict-status`** — the same level and job as the
|
||||
shipped `review-verdict.yml`, so the gate's own shape is what was measured.
|
||||
|
||||
@@ -137,24 +171,32 @@ controls ran under `permissive`); and the TOP-LEVEL declaration form the other f
|
||||
which is inferred rather than measured. That inference fails safe (a top-level key Gitea ignored is a
|
||||
security no-op, never a brick). Probe branches and PRs (#851, #852) deleted after reading.
|
||||
|
||||
**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. #747 re-probed SOME of the 1.25.4-pinned CI claims on 2026-08-28 —
|
||||
`ci.docs-only-skip-steps`, `ci.shared-pr-file-enumeration`, the REST-route half of
|
||||
`ci.batch-pushes-no-cancel-route`, the `compare` half of `ci.exemption-provenance` and the
|
||||
`branches: [main]` item in `ci.gate-trigger-base-resolved` — and it was **not** a sweep of the
|
||||
corpus. Other 1.25.4-dated CI claims remain un-re-run, the `creator`-attribution measurement the H10
|
||||
allow-list rests on (`review-verdict.yml`) among them. It **did not** discharge this record's own
|
||||
remaining items either: the `v1.25.4` scope enum, the `reqRepoWriter` gate and the `write:package` 403
|
||||
all stay 1.25.4-dated. Only the 403 needs a scoped PAT minted against the live instance; the scope enum
|
||||
and the `reqRepoWriter` gate were established by reading Gitea's source at the version tag (see
|
||||
"Measured vs inferred" above), so re-running those needs only a source read at `v1.27.1`. #747 did
|
||||
neither. What #747 did settle elsewhere is the merge-path half — see `release.main-direct-push-disabled`
|
||||
for the measured
|
||||
`block_admin_merge_override` semantics and for why `enable_bypass_allowlist` is not a substitute.
|
||||
**Version history — the 1.25.4 caveat, and what discharged it.** The instance was upgraded mid-session
|
||||
on 2026-08-05 (#743), which left this record's measurements dated against a version it no longer ran.
|
||||
#747 re-probed SOME of the 1.25.4-pinned CI claims on 2026-08-28 — `ci.docs-only-skip-steps`,
|
||||
`ci.shared-pr-file-enumeration`, the REST-route half of `ci.batch-pushes-no-cancel-route`, the
|
||||
`compare` half of `ci.exemption-provenance` and the `branches: [main]` item in
|
||||
`ci.gate-trigger-base-resolved` — and it was **not** a sweep of the corpus. #869 then swept the corpus
|
||||
by deriving the population with `git ls-files` rather than from an issue's item list, and re-established
|
||||
most of it — but "finished" would be too strong. Several findings remain 1.25.4-dated by decision, and
|
||||
**nothing here tracks which ones, deliberately.** Two attempts at that tracking both failed inside the
|
||||
commit that wrote them: an enumerated list named three residuals and omitted three the same commit
|
||||
created, and its replacement — a `git grep` pointer — missed the residual that same commit created,
|
||||
because the commit had introduced a second spelling the grep did not match. A second copy of a set,
|
||||
whether written out or derived by a pattern, is one more thing to keep in step with the set. Each
|
||||
residual states its own reason where it lives, which is the copy that cannot drift from its subject.
|
||||
This record's own
|
||||
three items are re-established on 1.27.1 above (2026-09-02, #869), so they may now be cited as current. What #747 settled elsewhere is the merge-path half — see
|
||||
`release.main-direct-push-disabled` for the measured `block_admin_merge_override` semantics and for why
|
||||
`enable_bypass_allowlist` is not a substitute.
|
||||
|
||||
**Read the two source items as source-attested, not instance-attested, and know the difference.** The
|
||||
scope enum and the `reqRepoWriter` gate are established by reading Gitea's source at tag `v1.27.1`,
|
||||
which is the version this instance reports (`GET /api/v1/version` -> `1.27.1`, 2026-09-02). That is a
|
||||
strong claim about the code and a weaker one about the deployment: a patched or vendored binary
|
||||
reporting `1.27.1` would satisfy the version read and not the source read. Nothing suggests one here,
|
||||
and the third item — the live 403, whose error string enumerates the required scope — is what ties the
|
||||
source to this instance. Cite the trio together rather than the source pair alone.
|
||||
|
||||
**Surviving routes — this record is not a mitigation for any of them.** `RENOVATE_TOKEN` is a
|
||||
`write:repository` bot PAT in the same store and still cannot be scoped down, because Renovate needs
|
||||
@@ -176,9 +218,13 @@ inherited, which is the residual the allow-list explicitly does not touch. **Whi
|
||||
was settled in #853** (`ci.workflow-dispatch-ref-unrestricted`): not the four unrestricted
|
||||
`workflow_dispatch` triggers but `docker-build.yml`'s head-resolved `pull_request:`, which runs
|
||||
attacker-authored YAML and therefore reaches EVERY secret in the store, not only the ones the
|
||||
committed workflows name — six of its jobs already hold `REGISTRY_PASSWORD` on that route, two of them
|
||||
required contexts. Residuals in #885. `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).
|
||||
committed workflows name. Six of its jobs held `REGISTRY_PASSWORD` on that route, two of them required
|
||||
contexts; #885 removed all six (`ci.pr-route-carries-no-stored-credential`), which narrows the blast
|
||||
radius and leaves the ROUTE exactly as cheap as before — the store, not the committed references, is
|
||||
what head YAML reaches. `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. Since #885 that push is whitelisted to
|
||||
`timothy` (`release.tag-protection-v-star`), which closes it against the bot and not against the
|
||||
admin.
|
||||
**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
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
key: ci.batch-pushes-no-cancel-route
|
||||
title: '2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4; REST route re-probed on 1.27.1 (#542)'
|
||||
title: '2026-07-21 — Batch your pushes: no agent-side cancel route; REST absence confirmed in the v1.27.1 router source, web UI still 1.25.4-dated (#542, #869)'
|
||||
status: active
|
||||
since: '2026-07-21'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 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.
|
||||
signals: 'cancel run 404 · Gitea 1.25.4 · Gitea 1.27.1 · `POST /api/v1/.../actions/runs/{id}/cancel` · MCP `actions_run_write` `cancel_run` · session+CSRF · `dispatch_workflow` · orphaned run · 4-slot runner · paths: n/a · issues: #542'
|
||||
signals: 'cancel run 404 · Gitea 1.25.4 · Gitea 1.27.1 · `POST /api/v1/.../actions/runs/{id}/cancel` · MCP `actions_run_write` `cancel_run` · session+CSRF · `routers/api/v1/api.go` · `dispatch_workflow` · orphaned run · 4-slot runner · paths: n/a · issues: #542, #869'
|
||||
mechanics: Gitea Actions REST + MCP `actions_run_write`; operator-only cancel in the browser UI.
|
||||
---
|
||||
|
||||
@@ -18,7 +18,9 @@ session cookie).
|
||||
**Re-probed on 1.27.1, 2026-08-28 (#747) — the REST route only.** `POST .../cancel` still returns
|
||||
**404**, and the live swagger shows the absence is structural rather than one 404: the only POST routes
|
||||
under `actions/runs/{run}` are `rerun`, `rerun-failed-jobs` and `jobs/{job_id}/rerun`. The MCP
|
||||
`cancel_run` and web-UI/CSRF findings above were **not** re-run and stay 1.25.4-dated. Swagger also
|
||||
`cancel_run` and web-UI findings above were **not** re-run and stay 1.25.4-dated. What is said about
|
||||
each at the end of this record differs, deliberately: the MCP one has a stated but SESSION-LOCAL
|
||||
reason (#869), and the web-UI one has none beyond being unchanged in kind. Swagger also
|
||||
lists `DELETE /actions/runs/{run}` (delete a run record); it was not probed — do not assume it cancels
|
||||
a run or frees a runner slot. **Only the operator can cancel, in the browser** — so if you must supersede a live
|
||||
run, say so explicitly instead of leaving it burning. (`dispatch_workflow` is a different route and
|
||||
@@ -26,3 +28,34 @@ still works for re-triggering a **main** run.)
|
||||
|
||||
This corrects the older "superseded runs drain on their own" framing: they do finish, but they hold
|
||||
one of the 4 runner slots while doing it, which is a real cost with several parallel sessions.
|
||||
|
||||
**Confirmed from SOURCE at tag `v1.27.1`, 2026-09-02 (#869), which upgrades what the claim rests on.**
|
||||
`routers/api/v1/api.go` registers the entire `/repos/{o}/{r}/actions/runs/{run}` group and it holds no
|
||||
cancel route in any method: `GET ""`, `GET /attempts/{attempt}`, `GET /attempts/{attempt}/jobs`,
|
||||
`DELETE ""` (`repo.DeleteActionRun`), `POST /rerun`, `POST /rerun-failed-jobs`, `GET /jobs`,
|
||||
`POST /jobs/{job_id}/rerun`, `GET /artifacts`. The only occurrence of `cancel` in that entire router is
|
||||
`repo.CancelScheduledAutoMerge`, an auto-merge route and unrelated. Reading the source is what draws a
|
||||
distinction a status code cannot: a 404 is equally consistent with an absent route, a mistyped path and
|
||||
a permission refusal, and only the first justifies "there is no route".
|
||||
|
||||
**Scope that read honestly: it covers the REST API surface and nothing else.** `routers/api/v1/api.go`
|
||||
cannot speak for the web UI's own routes, for the runner protocol, or for anything an agent driving an
|
||||
authenticated browser session could reach. "Operator-only in the browser" therefore still rests on the
|
||||
1.25.4 web-UI finding, not on this source read.
|
||||
|
||||
**Why the other two stay dated (#869, 2026-09-02) — and the honest reason, which is not the tidy one.**
|
||||
The MCP `cancel_run` tool was NOT re-invoked this session because the agent harness refused the call.
|
||||
That is a session-local constraint, not a property of this repo, and it is recorded as such rather
|
||||
than dressed up as an impossibility.
|
||||
|
||||
An earlier draft of this paragraph did dress it up — it claimed there was "no disposable run to spend"
|
||||
because nothing triggers on a non-`main` push. **That reason is false and cold review caught it.**
|
||||
Four of the six workflows carry `workflow_dispatch:`, and `dependency-scan.yml` is read-only while
|
||||
`renovate.yml` defaults its dispatch to `dryRun: full` (log only, no PRs), so either raises a
|
||||
disposable run on demand. A scratch PR raises runs too, which is how #747 and #748 probed. And the
|
||||
premise does not even need a disposable run: invoking `cancel_run` against an already-FINISHED run id
|
||||
costs nothing, and that is the right re-probe whenever a session can make the call.
|
||||
|
||||
What IS established: the REST surface carries no cancel route at v1.27.1 (above), which explains the
|
||||
tool's 404 mechanistically — but that is an inference about a wrapper whose own source was not read
|
||||
here, so it is a strong explanation and not a measurement. The web-UI finding is unchanged in kind.
|
||||
|
||||
@@ -23,9 +23,14 @@ behind a green required check. Closed unmerged, branches deleted, no forged `h10
|
||||
**Why a base BINDING and not a pinned diff.** Diffing two immutable shas would close it outright;
|
||||
Gitea cannot serve that: `compare/{base}...{head}` returns an object whose only keys are `commits`
|
||||
and `total_commits`, no `files` — measured on 1.25.4 and **re-confirmed on 1.27.1, 2026-08-28 (#747)**,
|
||||
probed on `b16ec15d6c1e...9b4844c1a8a9`, 5 commits. Separately, and **not re-probed since 1.25.4**, a
|
||||
`--depth=1` fetch of the two shas has no merge base, so three-dot is impossible and two-dot
|
||||
over-reports everything `main` gained since the branch point. So the base is read before the first page
|
||||
probed on `b16ec15d6c1e...9b4844c1a8a9`, 5 commits. Separately, a `--depth=1` fetch of the two shas has
|
||||
no merge base, so three-dot is impossible and two-dot over-reports everything `main` gained since the
|
||||
branch point. **Re-probed 2026-09-02 (#869), and the re-probe corrected how it is FILED: this is a GIT
|
||||
property, not a Gitea one, so it was never 1.25.4-dated in the first place.** Carrying a Gitea version
|
||||
stamp on it invited a re-run against the wrong axis — a Gitea upgrade cannot change it, and a git
|
||||
upgrade can. Measured on git 2.55.0 in a disposable local repo: after `git fetch --depth=1 origin
|
||||
<base> <head>`, `.git/shallow` is present, `git merge-base` exits **1**, and `git diff <base>...<head>`
|
||||
exits **128** with `fatal: ...: no merge base`. Re-probe it against a GIT version. So the base is read before the first page
|
||||
and after the last, and **the gap is stated plainly**: a retarget opening *and* closing strictly
|
||||
between the files call and the re-read stays invisible from inside the enumeration.
|
||||
|
||||
|
||||
@@ -28,6 +28,58 @@ and the base definition posted `h10=pending` on `opened` and `synchronize` alike
|
||||
establishing the residual below — is counted because omitting it turns an honest partial into an
|
||||
overclaim. Probes posted only probe-named contexts, never a forged `h10`. Full results in #699.
|
||||
|
||||
**The DEFINITION-RESOLUTION half re-established FROM THE v1.27.1 SOURCE, 2026-09-02 (#869)** — the
|
||||
mechanism behind the measurement, without spending four more scratch PRs. **Be precise about which
|
||||
half**, because an earlier draft of this block overclaimed and cold review caught it: `notify()`
|
||||
establishes only WHICH COMMIT each trigger reads its definition from. It says nothing about which
|
||||
`types` produce a run, and nothing about secrets availability — the file's only other
|
||||
`pull_request_target` reference is `ifNeedApprovalWith`, an APPROVAL gate, not a secrets gate.
|
||||
|
||||
**What IS established on 1.27.1, stated no wider than it was measured:** `pull_request_target` fires
|
||||
for `review-verdict.yml` continuously — **210 such runs across 189 distinct head shas**, counting only
|
||||
runs started strictly after the 2026-08-05 upgrade (measured 2026-09-02). All 210 belong to this
|
||||
workflow. The WINDOW is part of the claim: the all-time REPO-WIDE figure is 258 runs / 233 shas (this
|
||||
workflow's own all-time count is 250 — the difference is **8 runs** from three since-deleted probe
|
||||
workflows, all pre-upgrade), which splits 44 before the upgrade date + 4 ON it + 210 after, so citing
|
||||
258 here would import 1.25.4 evidence into a 1.27.1 sentence. The 4 same-day runs are excluded rather
|
||||
than assigned — the upgrade landed mid-session and their 17:41-22:36 **UTC** start times cannot be
|
||||
placed either side of it without the exact upgrade time, so they go to the side that weakens the
|
||||
claim. Read those times as UTC deliberately: this endpoint returns `started_at` Z-suffixed, while
|
||||
other Gitea timestamps render in the host zone, Europe/Brussels — `+02:00` in August, `+01:00` in
|
||||
winter — so a UI comparison shows different clock times. **What that
|
||||
deliberately does NOT establish:** WHICH `types:` fire, since a run count identifies no event type;
|
||||
and whether the STORED secret store is reachable under this trigger, since the job references only the
|
||||
injected `secrets.GITEA_TOKEN`. Both stay 1.25.4-dated.
|
||||
|
||||
Two earlier attempts at this paragraph are worth not repeating. The first re-dated BOTH halves —
|
||||
over-retreating past what production already shows. The second over-corrected into a per-PR
|
||||
completeness claim ("every PR merged since the upgrade carries this job's context"), which is false
|
||||
and refuted by this record's own PR #863 observation — that PR merged carrying "no review-verdict
|
||||
context of any kind", because a PR into a scratch base does not match `branches: [main]`, the very
|
||||
filter this record exists to explain. (Cited by content rather than by position: a "two paragraphs
|
||||
down" pointer was wrong the one time it was written, and a positional reference breaks whenever
|
||||
anything is inserted above it.) A blunt run count needs no per-PR census and cannot rot that way.
|
||||
|
||||
Now the mechanism itself. In `services/actions/notifier_helper.go`, one function resolves both. For a
|
||||
PR event `input.Ref` resolves
|
||||
to the PR's own HEAD ref — `WithPullRequest` sets `input.Ref = git.RefName(pr.GetGitHeadRefName())`
|
||||
when the caller has not already supplied one; that ref's commit is passed to
|
||||
`DetectWorkflows`, and every workflow found there is kept **only if** its trigger is NOT
|
||||
`pull_request_target` (`if wf.TriggerEvent.Name != actions_module.GithubEventPullRequestTarget`).
|
||||
Then, separately and only when `input.PullRequest != nil`, it resolves `baseRef := git.BranchPrefix +
|
||||
input.PullRequest.BaseBranch`, calls `DetectWorkflows` again on that commit, and from THAT set keeps
|
||||
only workflows whose trigger **is** `pull_request_target`. So the two triggers read their definitions
|
||||
from two SEPARATELY RESOLVED refs — head and base — and a head rewrite cannot reach a
|
||||
`pull_request_target` definition at all. (Separately resolved, not necessarily different: nothing
|
||||
forbids the two refs resolving to the same commit, which is simply the uninteresting case where the
|
||||
rewrite is not a rewrite.)
|
||||
|
||||
The same lines make the `branches: [main]` filter's necessity concrete rather than cautionary: the
|
||||
base is resolved by BRANCH NAME (`BranchPrefix + BaseBranch`), not by a pinned sha, so "the base
|
||||
definition" is whatever that branch points at when the event fires. That is why a PR opened into an
|
||||
attacker-pushed base branch would run that branch's gate, and it is the same re-resolution the
|
||||
retarget fence exists to fence (`ci.verdict-write-retarget-fence`).
|
||||
|
||||
That third item was **re-confirmed at the STATUS level on 1.27.1, 2026-08-28 (#747)**, as a by-product
|
||||
of an unrelated probe (PR #863): a PR from a scratch head into a scratch base carried thirteen `pull_request`
|
||||
contexts and no review-verdict context of any kind. The run list itself was not re-enumerated, so the
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
key: ci.image-build-delegates-the-spa-suite
|
||||
title: '2026-08-30 — the image build builds the SPA and does not test it (#887)'
|
||||
status: active
|
||||
since: '2026-08-30'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: '`docker/Dockerfile` runs no vitest suite. Its web-build stage lints, typechecks and BUILDS the SPA; the suite runs once, unfiltered, in `docker-build.yml`''s `test` job on a real checkout, and `build` carries `needs: [test, migrations, scan]` so no image is published past a red suite. THAT EDGE IS NOW THE ONLY LAYER, so the guard checks it is real and not merely present — and it does so by PINNING TEXT rather than parsing it. The commands each SPA-carrying Dockerfile stage runs, and the gating step''s `run:` body and `if:`, are compared as strings against a declared pin; the step and its job must carry no `continue-on-error` in any spelling, the job no job-level `if:`, and the publish step must keep its own `docs_only` gate. A guard that asks what a command MEANS was wrong nine times in three review rounds. A pin is immune to a different SPELLING of the command, which is that whole class; it is not immune to the same text meaning something else, so the routes to that are pinned or refused too: `working-directory` and the step''s own `shell:`, the workflow `defaults.run.shell` and the job-level `defaults` overriding it, a stage `SHELL`, `web/package.json`''s script map (pinned WHOLE — selecting on the literal `vitest` missed `npm run test`, `npm t` and the `prebuild`/`preinstall` lifecycle hooks), `web/vite.config.ts` PINNED WHOLE — it decides what the suite collects and what `vite build` loads, and pinning a BLOCK of it was defeated seven measured ways across three rounds, so the partial match was withdrawn rather than respelled an eighth time; and the ABSENCE of any config file that outranks it — `vitest.config.*` for vitest, and `vite.config.js`/`.mjs` for `vite build`, whose `DEFAULT_CONFIG_FILES` lists them BEFORE `.ts`. Pinning one file is worthless while a second can outrank it, and that family went short twice. The gating job''s `container:` image is left to `ci.image-pin-population` rather than guarded twice. What is unmodelled is a LIST, not an "only": an `ENV` changing what a pinned `RUN` resolves, the plugin BODIES, and a publish through another action. The count of such routes lives in the `docs/guard-inventory.md` row and nowhere else — it briefly existed in three places with two different values, which is how a number stays wrong. It is a running total, wrong at every count so far. They share one shape, which is the transferable part: A PIN ASSUMES IT IS PINNING THE ARTIFACT THAT STILL DECIDES, and every route found so far is authority moving to another file, another occurrence, another workflow, or a hook the pinned command invokes. Do NOT restore a filtered in-image run by naming the specs that cannot run there: that list is a population nothing derives, and the red it produces when it goes stale is unreachable on a PR — `Build & push image (amd64)` is `if: github.event_name != ''pull_request''` — so it lands on `main` and on the `v*` tag path, where it fails the release cut. Held in both directions by `scripts/tests/test_image_build_delegates_the_spa_suite.py`.'
|
||||
signals: 'image build fails on main only · every image build fails · latest not republished · release cut fails at the image build · gitless build STAGE not context · node:22-bookworm-slim ships no git · virtual:etv-tracked-source-files could not read the git index · hand-maintained exclude list · exclusion list is the underived population · suite members needing the git binary vs a checkout · COPY .git would not help · needs edge is the only gate · unfiltered gating run · paths: `docker/Dockerfile`, `.gitea/workflows/docker-build.yml`, `scripts/tests/test_image_build_delegates_the_spa_suite.py`, `web/vite-plugins/trackedSourceFiles.ts` · issues: #887, #883, #819, #806, #420'
|
||||
mechanics: 'Populations are DERIVED from the git index (tracked Dockerfiles and workflows, and the stages within them carrying the SPA source); the command lines in those places are PINNED as text. A population decides what is CHECKED, so a hand-written one goes silently short; a pin decides what is EXPECTED, so a stale one goes loudly red. Publishing jobs come from the `docker/build-push-action` step and the Dockerfile each builds from that step''s `file:` input, so `ci-image.yml` is out of scope by derivation rather than by an exemption. 73 mutants, 0 missed (development battery).'
|
||||
---
|
||||
|
||||
The web-build stage is gitless twice over. It copies only `web/` and `design-system/`, so it holds
|
||||
no `.git`. That is a property of the STAGE, not of the build context — the context is the repository
|
||||
root (`context: .`) and `.dockerignore` does not exclude `.git`, so the directory is available to be
|
||||
copied and simply is not; stating it the other way invites a reader to check, find `.git` in the
|
||||
context, and conclude the whole note is stale. And `node:22-bookworm-slim` ships no git binary. Members of the SPA
|
||||
suite need one or the other: `pageSizeCallSites.guard.test.ts` and
|
||||
`completeAnnotations.guard.test.ts` derive their file population from `git ls-files`
|
||||
and refuse to fall back to a directory walk (`testing.guard-derives-population-from-source`), and
|
||||
`trackedSourceFiles.realgit.test.ts` builds a real temporary repository. Because the two
|
||||
prerequisites OVERLAP rather than divide, adding a `COPY .git` fixes neither: the binary is still
|
||||
missing.
|
||||
|
||||
So running the suite there at all costs a list of the members that cannot run, maintained by hand
|
||||
beside a suite that grows. That list went stale the first time a guard was added without updating
|
||||
it, and the failure it produced was invisible where it could have been cheap and fatal where it was
|
||||
not: green on every PR, red on `main` and on the release tag.
|
||||
|
||||
**Why not the alternatives.** Each was considered and rejected on its own evidence, not by analogy:
|
||||
|
||||
* **Add a third `--exclude`.** Re-arms the same trap for the next guard, which is what the issue was
|
||||
filed to prevent.
|
||||
* **Derive the exclusions from source.** Buildable, but it requires a predicate for "needs git" over
|
||||
spec text — two unrelated shapes today (a virtual-module import, a `git` spawn) and no reason to
|
||||
think that stays two. A string predicate over source is the wrong instrument for a set whose
|
||||
members are defined by what they DO at runtime.
|
||||
* **Give the container git** (`apt-get install git` plus a real `.git`). It works, and it costs a
|
||||
cache-busting `COPY` of VCS metadata on every commit, so the whole lint/typecheck/test/build layer
|
||||
re-runs for changes that do not touch `web/`. Synthesising a repository in the image instead —
|
||||
`git init && git add -A` — is worse than it looks: the resulting index is a filesystem walk with
|
||||
extra steps, which is precisely the population the guards refuse.
|
||||
|
||||
**What the removal gives up, enumerated rather than waved through**
|
||||
(`process.enumerate-workaround-behaviors-before-deleting`):
|
||||
|
||||
1. *Blocking a publish on a red suite* — retained, by the `needs:` edge. Two skips exist inside
|
||||
`test`. `docs_only` also gates `Build and push`, so that arm ships nothing — ASSERTED, by
|
||||
`test_the_DOCS_ONLY_arm_cannot_publish_an_image`, after cold review pointed out it had been
|
||||
stated as fact with nothing checking it. The #420 revalidate skip fires only on a tree
|
||||
byte-identical to a head that already carried a green combined status; that arm is a DEPENDENCY
|
||||
on `scripts/ci-detect-already-validated.sh` rather than something asserted here, and that script
|
||||
is graded `MUTATION: NONE`. Said plainly because #887 is what made the second arm load-bearing:
|
||||
before it, the in-image run covered that case.
|
||||
2. *Running the suite under `node:22-bookworm-slim` specifically* — **lost**, and this is the real
|
||||
cost. It is small because the suite is jsdom and pure JS, and because the artifact the image
|
||||
actually ships is the output of `vite build`, which still runs there: a test that passes in the
|
||||
CI toolchain image and fails in bookworm-slim would be telling us about the test environment, not
|
||||
about the image.
|
||||
3. *`docker build` running the suite for a developer locally* — lost; `npm test -- --run` is the
|
||||
direct route and is what `docs/testing.md` documents.
|
||||
|
||||
**The guard's own mechanism was replaced once, and the reason belongs here rather than only in the commit log, because the next person to widen it needs it.** Three versions asked whether a given command runs the suite and whether its failure can be swallowed. That predicate was wrong nine times across three cold-review rounds — executed heredocs treated as data, `#` truncating a command mid-word (including the live `${#reports[@]}` idiom), compound punctuation welding commands, `npm t` and `./node_modules/.bin/vitest` and `timeout`/`su -c` wrappers unrecognised, `true || npm test` counted as a run it never performs, and `continue-on-error: ${{ … }}` slipping a two-literal check. Twice, a clause added to remove a FALSE RED opened a FALSE GREEN on the guard's headline assertion. The mechanism was withdrawn rather than patched a tenth time: the risky command lines are now compared against a pin, so no spelling has to be recognised in order to be rejected. The cost is real and is the point — a legitimate edit to those lines reddens the guard and its author updates the pin deliberately.
|
||||
|
||||
A fourth cold review then attacked the pin itself and found the honest limit: the replacement removes the SPELLING problem outright, but the same pinned text can be made to mean something else, and two mutants re-armed this very defect through `web/package.json` — `RUN npm run build` executes whatever that file says — with every pin still matching. A fifth round then found the same mistake inside the fix: the package.json check SELECTED on the literal `vitest` where a PIN was available, and four one-line edits that never spell it — `npm run test`, `npm t`, and the `prebuild`/`preinstall` lifecycle hooks — each re-armed the defect with every other pin matching. The whole script map is pinned now, and so is `web/vite.config.ts` — WHOLE, after three further
|
||||
rounds each defeated one spelling of a partial match of it, and two more routes bypassed the marker
|
||||
entirely because `defineConfig` is the identity function and a later spread replaces what was
|
||||
matched.
|
||||
|
||||
The general lesson is worth more than any of the fixes: replacing a predicate with a pin moves the problem from "can the checker recognise this?" to "is the checked text still the whole story?". The second question has a short, workable answer where the first did not — but it is still a question, the route list has been wrong at every count so far, and the honest form of that is a running total with its history attached rather than a closure claim.
|
||||
|
||||
`lint` and `typecheck` stay in the stage. They are gitless-safe with no member that is not, so they
|
||||
carry no list and no trap. Whether the image build should run them at all is a separate question
|
||||
this does not answer, and leaving them is not an argument that it should.
|
||||
File diff suppressed because one or more lines are too long
@@ -41,7 +41,7 @@ context, which gives the login specs a signed-out browser without a logout dance
|
||||
CI — a retry lets a flaky flow merge looking green. Coupling worth knowing: vitest's default `include`
|
||||
glob would run `web/e2e/*.spec.ts` under jsdom, so `vite.config.ts` excludes `e2e/**` by spreading
|
||||
`configDefaults.exclude` — not by narrowing `include` to `src/**`, which would silently stop collecting
|
||||
the real vitest test under `web/scripts/`.
|
||||
the real vitest tests under `web/scripts/`.
|
||||
|
||||
**Lifecycle correctness in `scripts/e2e-ui.sh`** — all four found by adversarial review, **none by a
|
||||
passing run**; that is the transferable lesson (green runs never exercise the failure/interrupt paths).
|
||||
|
||||
@@ -21,6 +21,13 @@ closes the permanent case is the post-POST re-count recorded at `ci.verdict-unve
|
||||
**The `push`-supersession half stays 1.25.4-dated (#747, 2026-08-28), and the cheap probe route is
|
||||
gone.** No workflow triggers on a push to a non-`main` branch, and `main` refuses direct pushes, so the
|
||||
only remaining route is two `push`es to `main` — i.e. two PR merges — landing close together.
|
||||
Both halves of that reason were RE-CHECKED on 2026-09-02 (#869) rather than carried forward, because a
|
||||
reason a claim stays dated goes stale exactly like the claim does. All six workflows were enumerated
|
||||
from the tracked tree and the trigger half still holds; the protection half was re-read live the same
|
||||
day. Note the scope: that re-check covers the REASON, not the two measurements themselves, which were
|
||||
not re-run. And it licenses only the "no PUSH-triggered scratch run" half — four of the six workflows
|
||||
carry `workflow_dispatch:`, so a disposable run IS raisable on demand; what is gone is the cheap
|
||||
scratch-BRANCH push probe these particular measurements used.
|
||||
Supersession is per-ref, so a `v*` tag push is not one: `docker-build.yml` triggers on it, but each
|
||||
release tag is a ref pushed once, with nothing to supersede. That route is real and arrangeable, but it
|
||||
is not the disposable scratch-branch probe this record's other push measurements used, and the
|
||||
@@ -272,16 +279,58 @@ here rewritten because what it leaves behind still bounds the fence.
|
||||
|
||||
**The two walks do NOT now agree, and an earlier draft of this record said they did.** What they
|
||||
share is only the cap treatment — "the 20th request has to be the EMPTY terminator". Their
|
||||
TERMINATION rules are now opposite: `page_statuses` still returns on the FIRST empty page, and
|
||||
this walk changed away from that. Whether it should follow is not settled here, because the
|
||||
defect that forced this change has not been shown to exist on `/statuses/{sha}`: measured
|
||||
2026-08-30, that endpoint returns a TRUE `X-Total-Count` (105 on a 105-row head, at both
|
||||
`?limit=1` and `?limit=50`), where `/issues/{n}/timeline` returns the post-filter page length.
|
||||
A true total is consistent with counting before filtering and so with a terminator that means
|
||||
what it says — evidence, not proof, and the reason it is only evidence is that no filtering
|
||||
predicate on that endpoint has been exhibited either way. Tracked rather than asserted:
|
||||
ersatztv#893. "Require K consecutive empty pages" was rejected: K is arbitrary and only moves the
|
||||
bar to 50K filtered rows.
|
||||
TERMINATION rules are opposite: `page_statuses` returns on the FIRST empty page, and this walk
|
||||
changed away from that.
|
||||
|
||||
**The asymmetry is CORRECT, and it is correct because the two ENDPOINTS differ — established from
|
||||
the v1.27.1 source on 2026-09-02 (ersatztv#893), which closes the question this record previously
|
||||
only tracked.** `GET /repos/{o}/{r}/statuses/{sha}` is `repo.GetCommitStatuses` ->
|
||||
`getCommitStatuses` in `routers/api/v1/repo/status.go`. It calls
|
||||
`db.FindAndCount[git_model.CommitStatus]` with `CommitStatusOptions{ListOptions, RepoID, SHA,
|
||||
SortType, State}`, then builds the response with an UNCONDITIONAL loop —
|
||||
`for _, status := range statuses { apiStatuses = append(apiStatuses, convert.ToCommitStatus(ctx,
|
||||
status)) }`. There is no `continue`, no predicate and no nil-drop: `convert.ToCommitStatus` returns
|
||||
a struct pointer for every row it is handed. `CommitStatusOptions.ToConds()` builds the ONLY filter
|
||||
— `repo_id`, `sha`, and an optional `state` — and it is a SQL `WHERE`, evaluated by the database in
|
||||
the SAME query that carries the `LIMIT`/`OFFSET`, never as a pass over the rows after they come
|
||||
back. So the serialized page length equals the database page length, and an empty page really does
|
||||
mean "past the end".
|
||||
|
||||
That is the precise property `/issues/{n}/timeline` lacks. `ListIssueCommentsAndTimeline` calls
|
||||
`FindComments` with the `ListOptions` (so LIMIT/OFFSET at the database level) and then appends
|
||||
CONDITIONALLY — `if comment.Type != issues_model.CommentTypeCode &&
|
||||
isXRefCommentAccessible(ctx, ctx.Doer, comment, issue.RepoID)`. Note that is TWO predicates, not
|
||||
one: the row type AND a per-viewer accessibility check, so the number of rows dropped is not even a
|
||||
property of the issue alone. That is what makes a full page of filtered rows byte-identical to the
|
||||
end of the list. The defect #870 fixed therefore does not exist on `/statuses/{sha}`, and adopting
|
||||
#870's treatment here would buy nothing for 20 requests per walk on the post-write path, where
|
||||
latency is least welcome.
|
||||
|
||||
**The same two handlers explain the terminator SHAPES this record measured per endpoint, which
|
||||
until now were an empirical table.** `getCommitStatuses` builds its slice with
|
||||
`make([]*api.CommitStatus, 0, len(statuses))` — a non-nil empty slice, which serializes as `[]`.
|
||||
`ListIssueCommentsAndTimeline` declares `var apiComments []*api.TimelineComment` — a nil slice when
|
||||
nothing appends, which serializes as bare `null`. So "`/statuses/{sha}` returns `[]`,
|
||||
`/issues/{n}/timeline` returns `null`" is a source-level property of how each handler initialises
|
||||
its slice, not a coincidence to be re-measured after every upgrade. It still has to be re-read if
|
||||
either handler is rewritten — but a reader can now check it in the source instead of constructing a
|
||||
past-the-end request.
|
||||
|
||||
**The header measurement is now explained rather than merely suggestive.** Measured 2026-08-30,
|
||||
`/statuses/{sha}` returns a TRUE `X-Total-Count` (105 on a 105-row head, at both `?limit=1` and
|
||||
`?limit=50`) where `/issues/{n}/timeline` returns the post-filter page length. The source says why,
|
||||
on both sides: `getCommitStatuses` calls `ctx.SetTotalCountHeader(maxResults)`, where `maxResults`
|
||||
is `FindAndCount`'s SQL COUNT over the same conditions — computed by the database, never from the
|
||||
serialized page — while the timeline calls
|
||||
`ctx.SetTotalCountHeader(int64(len(apiComments)))`, literally the length of the filtered page it
|
||||
just built, which is why that header cannot derive a page count there. Re-confirmed live on
|
||||
2026-09-02 on a head where the two necessarily differ: page 1 returned 50 rows with
|
||||
`X-Total-Count: 63`. Previously this was recorded as "evidence, not proof" because no filtering
|
||||
predicate had been exhibited either way; the absence is now read off the handler directly, so it is
|
||||
settled. **Do not "tidy" the two walks together** — they differ because their endpoints differ.
|
||||
|
||||
"Require K consecutive empty pages" was rejected: K is arbitrary and only moves the bar to 50K
|
||||
filtered rows.
|
||||
|
||||
Three measurements at Gitea 1.27.1 (2026-08-30) rule out the cheaper fixes, and are recorded
|
||||
because each is the kind of thing a later reader would otherwise re-propose. `X-Total-Count` on
|
||||
|
||||
@@ -6,8 +6,8 @@ since: '2026-08-30'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
stale-after: '2027-02-28'
|
||||
rule: 'Gitea 1.27.1 offers NO mechanism to restrict `workflow_dispatch` by ref, and has no protected-environment concept at all — PROBED across the REST API, the loaded config and the CLI, not assumed (the WEB UI was not swept; the body says why that is acceptable here and where it would matter). The dispatch body schema `CreateActionWorkflowDispatch` makes `ref` a required free-form string with no allow-list or pattern field; zero of the 308 documented API paths contain "environment", and Actions secrets exist only at org/repo/user scope with no per-ref or per-environment gate; `/api/v1/settings/actions` 404s; the config file the running server actually loads (`/etc/gitea/app.ini`, named by its own `--config`) sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`; and the `gitea` CLI exposes exactly ONE Actions subcommand, `gitea actions generate-runner-token`, which registers a runner and restricts nothing. Treat the VERSION, not the `stale-after` date, as the real trigger to re-probe: an upgrade past 1.27.1 invalidates every capability claim here the day it lands, months before the date fires. The four unrestricted dispatches (`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml`, `renovate.yml`) are therefore ACCEPTED — but the operative reason is NOT "repository write access is the boundary", which is the argument to avoid because it is unfalsifiable and it hides the real route. The operative reason is that **dispatch is not the cheapest route to ANY of it**: `docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that route executes ATTACKER-AUTHORED YAML — and such YAML can name any secret in the repo store, not merely the ones the committed workflows happen to reference (`ci.gate-trigger-base-resolved`, verbatim: "any PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store"). Label that step honestly: it is INFERRED from the repo-scoped secret model plus that record, NOT measured here, because the measurement would print a live credential into a run log. That generalizing step is what makes the argument cover all four rather than just the registry pair: `renovate.yml`''s `RENOVATE_TOKEN`/`GH_COM_TOKEN` are reachable from a PR without dispatching `renovate.yml` at all, and `dependency-scan.yml` references no `secrets.` whatever — which corrects #853''s own table row for it. On the registry credential as it stands, SIX jobs in `docker-build.yml` hold `REGISTRY_PASSWORD` and run on the PR route (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them — `test` and `migrations` — branch-protection required contexts per `.gitea/required-status-contexts.json`; carry that as the INVARIANT "every job on the PR route that NAMES `secrets.REGISTRY_PASSWORD`", never as the six-name list, because a remediation scoped to a stale list misses whatever lands next. Resist the tempting "every `container:` job" — `toolchain-preflight` is deliberately container-free and takes the credential through `ETV_REGISTRY_AUTH`, so that predicate names five of the six and reproduces on day one the exact staleness it was written to prevent. "Deliberate act" throughout carries `ci.toolchain-image-publish-is-a-dispatch`''s sense — an act OUTSIDE the ordinary contribution flow, not a raw step count: opening a PR costs zero such acts and a dispatch costs one. Restricting dispatch would therefore close the more visible route and change nothing. The residuals worth tracking are the PR route AND the `v*` tag push — a single act, explicitly outside `release.main-direct-push-disabled` — both in #885, not dispatch.'
|
||||
signals: 'workflow_dispatch ref restriction, restrict dispatch by branch, protected environment, environment-gated secret, Gitea 1.27.1 actions capabilities, settings/actions 404, attacker-supplied workflow YAML, head-resolved pull_request trigger, PR run gets repo secrets, REGISTRY_PASSWORD reachable from a PR, tag_protections empty, renovate bot write scope · paths: `.gitea/workflows/ci-image.yml`, `.gitea/workflows/docker-build.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/renovate.yml`, `docs/ci-cd.md` · issues: #853, #744, #885, #697, #742, #748, #845'
|
||||
rule: 'Gitea 1.27.1 offers NO mechanism to restrict `workflow_dispatch` by ref, and has no protected-environment concept at all — PROBED across the REST API, the loaded config and the CLI, not assumed (the WEB UI was not swept; the body says why that is acceptable here and where it would matter). The dispatch body schema `CreateActionWorkflowDispatch` makes `ref` a required free-form string with no allow-list or pattern field; zero of the 308 documented API paths contain "environment", and Actions secrets exist only at org/repo/user scope with no per-ref or per-environment gate; `/api/v1/settings/actions` 404s; the config file the running server actually loads (`/etc/gitea/app.ini`, named by its own `--config`) sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`; and the `gitea` CLI exposes exactly ONE Actions subcommand, `gitea actions generate-runner-token`, which registers a runner and restricts nothing. Treat the VERSION, not the `stale-after` date, as the real trigger to re-probe: an upgrade past 1.27.1 invalidates every capability claim here the day it lands, months before the date fires. The four unrestricted dispatches (`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml`, `renovate.yml`) are therefore ACCEPTED — but the operative reason is NOT "repository write access is the boundary", which is the argument to avoid because it is unfalsifiable and it hides the real route. The operative reason is that **dispatch is not the cheapest route to ANY of it**: `docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that route executes ATTACKER-AUTHORED YAML — and such YAML can name any secret in the repo store, not merely the ones the committed workflows happen to reference (`ci.gate-trigger-base-resolved`, verbatim: "any PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store"). Label that step honestly: it is INFERRED from the repo-scoped secret model plus that record, NOT measured here, because the measurement would print a live credential into a run log. That generalizing step is what makes the argument cover all four rather than just the registry pair: `renovate.yml`''s `RENOVATE_TOKEN`/`GH_COM_TOKEN` are reachable from a PR without dispatching `renovate.yml` at all, and `dependency-scan.yml` references no `secrets.` whatever — which corrects #853''s own table row for it. On the registry credential, SIX jobs in `docker-build.yml` held `REGISTRY_PASSWORD` on the PR route (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them — `test` and `migrations` — branch-protection required contexts per `.gitea/required-status-contexts.json`. That is CLOSED as of 2026-09-04 (#885, `ci.pr-route-carries-no-stored-credential`): nothing on the `pull_request` route names any stored secret — no job and no workflow scope — the toolchain image is pulled anonymously and the commit-status API read unauthenticated, and the PAT survives in `build`, which the route cannot reach. The invariant it is held by is "no part of a `pull_request`-triggered workflow may name a `secrets.*`" — every job the trigger reaches AND the workflow scope outside `jobs:`, since a root `env:`/`defaults:` is materialised into every job and no job-level `if:` can take it off the route — DERIVED from the git index by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`, never the six-name list, and never "every `container:` job" — `toolchain-preflight` was deliberately container-free and took the credential through `ETV_REGISTRY_AUTH`, so that predicate names five of the six and reproduces on day one the exact staleness it was written to prevent. NOTE WHAT THAT DID NOT CHANGE, which is this record''s whole point: head-supplied YAML can still name every secret in the store, so the PR route is still cheaper than any dispatch. "Deliberate act" throughout carries `ci.toolchain-image-publish-is-a-dispatch`''s sense — an act OUTSIDE the ordinary contribution flow, not a raw step count: opening a PR costs zero such acts and a dispatch costs one. Restricting dispatch would therefore close the more visible route and change nothing. The residuals were the PR route AND the `v*` tag push — a single act, explicitly outside `release.main-direct-push-disabled` — and both were addressed in #885 rather than by restricting dispatch: the tag push is now whitelisted to the release operator (`release.tag-protection-v-star`) and the PR route carries no stored credential. Neither makes dispatch worth restricting, because the remaining exposure — head YAML naming any secret in the store — is reached by opening a PR and is unaffected by either.'
|
||||
signals: 'workflow_dispatch ref restriction, restrict dispatch by branch, protected environment, environment-gated secret, Gitea 1.27.1 actions capabilities, settings/actions 404, attacker-supplied workflow YAML, head-resolved pull_request trigger, PR run gets repo secrets, REGISTRY_PASSWORD reachable from a PR, tag_protections, v* tag whitelist, renovate bot write scope · paths: `.gitea/workflows/ci-image.yml`, `.gitea/workflows/docker-build.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/renovate.yml`, `docs/ci-cd.md` · issues: #853, #744, #885, #697, #742, #748, #845'
|
||||
mechanics: '`docs/ci-cd.md` -> "CI toolchain image" -> "Publishing from a branch is a dispatch, not a push"'
|
||||
sources: 'Gitea 1.27.1 probe, 2026-08-30 — `swagger.v1.json` (308 paths, 0 matching "environment"); `CreateActionWorkflowDispatch` schema; live `tag_protections` = `[]`; live `branch_protections` = one `main` rule; `/api/v1/admin/users` = exactly `timothy` (admin) + `renovate` (non-admin), with `permission: write` from `/repos/timothy/ersatztv/collaborators/renovate/permission`; host `app.ini` `[actions]`; differential dispatch-authorization probe with anonymous and authenticated-non-writer controls'
|
||||
---
|
||||
@@ -47,16 +47,21 @@ sources: 'Gitea 1.27.1 probe, 2026-08-30 — `swagger.v1.json` (308 paths, 0 mat
|
||||
into a run log. **That is the step that makes this
|
||||
cover all four dispatches**, `renovate.yml` included, even though `renovate.yml` has no
|
||||
`pull_request:` trigger of its own; without it the argument would reach only the registry pair.
|
||||
Concretely on the registry credential today, SIX jobs hold `REGISTRY_PASSWORD` and run on the PR
|
||||
route — `toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format` — of
|
||||
which `test` and `migrations` are both branch-protection required contexts
|
||||
(`.gitea/required-status-contexts.json`). **Carry the invariant, not the list:** *every job on the PR
|
||||
route that names `secrets.REGISTRY_PASSWORD`*, because a remediation scoped to today's six names
|
||||
silently misses the seventh. Not "every `container:` job" — `toolchain-preflight` is deliberately
|
||||
container-free (`runs-on: small`, credential via `ETV_REGISTRY_AUTH`), so that predicate names five of
|
||||
six and is stale before it ships.
|
||||
So "push a branch, open a PR" reaches the credential with **no act outside the ordinary contribution
|
||||
flow**, where a dispatch costs one — that is this repo's sense of "deliberate"
|
||||
On the registry credential, SIX jobs held `REGISTRY_PASSWORD` on the PR route —
|
||||
`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format` — of which
|
||||
`test` and `migrations` are both branch-protection required contexts
|
||||
(`.gitea/required-status-contexts.json`). That was closed in #885
|
||||
(`ci.pr-route-carries-no-stored-credential`), by the invariant and not by the list: *no part of a
|
||||
`pull_request`-triggered workflow may name a `secrets.*`* — neither a job the trigger reaches nor
|
||||
the workflow scope outside `jobs:`, whose `env:`/`defaults:` are materialised into every job —
|
||||
derived from the git index, because a remediation scoped to those six names silently misses the
|
||||
seventh. Not "every `container:` job" either — `toolchain-preflight` is deliberately
|
||||
container-free (`runs-on: small`) and took the credential through `ETV_REGISTRY_AUTH`, so that
|
||||
predicate names five of six and is stale before it ships.
|
||||
**The route itself is untouched by that fix**, which is why this record still stands: head-supplied
|
||||
YAML can name any secret in the store, so "push a branch, open a PR" still reaches all of them with
|
||||
**no act outside the ordinary contribution flow**, where a dispatch costs one — that is this
|
||||
repo's sense of "deliberate"
|
||||
(`ci.toolchain-image-publish-is-a-dispatch`), not a raw step count, which would read 2 vs 2. Any
|
||||
control that restricted dispatch and left this open would close the more visible route and report
|
||||
progress that did not happen. Per-`if:` ref gates on the publish steps do not change this: they live
|
||||
@@ -76,11 +81,10 @@ sources: 'Gitea 1.27.1 probe, 2026-08-30 — `swagger.v1.json` (308 paths, 0 mat
|
||||
with `"not found"` while `renovate` on this repo reaches ref resolution with `"ref ... doesn't
|
||||
exist"` — the same response a known writer gets — so `renovate` passes the dispatch authorization
|
||||
gate.
|
||||
- **One cheap control exists and was deliberately NOT taken here: `tag_protections` is empty.** Gitea
|
||||
1.27.1 does support tag protection (`name_pattern` plus a username/team whitelist), and a `v*` rule
|
||||
whitelisting the release operator would close `docker-build.yml`'s tag-push row against the bot at
|
||||
no operational cost, since only the operator cuts releases. It is not applied in this change because
|
||||
it is live branch-protection-class configuration whose failure mode is a broken release cut, which
|
||||
deserves its own change and its own verification that a legitimate tag push still succeeds — not a
|
||||
rider on a decision record. It carries no weight against the admin actor either way. Tracked with
|
||||
the `pull_request:` residual in #885.
|
||||
- **One cheap control was identified here and applied in #885: the `v*` tag rule.** Gitea 1.27.1
|
||||
supports tag protection (`name_pattern` plus a username/team whitelist), and `tag_protections` was
|
||||
empty when this record was written (2026-08-30), leaving `docker-build.yml`'s tag-push row open
|
||||
against the bot. It was deliberately not applied as a rider on a decision record — its failure mode
|
||||
is a broken release cut, so it earned its own change and its own verification. It now exists as
|
||||
`v*` whitelisted to `timothy` (`release.tag-protection-v-star`), and it carries no weight against
|
||||
the admin actor, which was true when it was proposed and is true now.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,7 +6,7 @@ since: '2026-07-22'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: A channel can attach `GraphicsElement`s directly via a new `ChannelGraphicsElement` join table (a base layer under deco/playout-item elements), and a built-in text element (`on-now-next.yml`) is seeded once per database so the On Now/Next overlay works out of the box.
|
||||
signals: 'ChannelGraphicsElement, Channel graphics attachment, GraphicsElementSelector base layer, on-now-next seeded element, GraphicsElementDefaults.OnNowNextFileName, builtIn discriminator · paths: `ErsatzTV.Core/Domain/ChannelGraphicsElement.cs`, `ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs`, `ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs`, `ConfigElementKey.GraphicsOnNowNextSeeded`, `GraphicsElementResponseModel.BuiltIn` · issues: #74'
|
||||
signals: 'ChannelGraphicsElement, Channel graphics attachment, GraphicsElementSelector base layer, on-now-next seeded element, GraphicsElementDefaults.OnNowNextFileName, GraphicsElementDefaults.OnNowNextSeededPath, builtIn discriminator · paths: `ErsatzTV.Core/Domain/ChannelGraphicsElement.cs`, `ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs`, `ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs`, `ConfigElementKey.GraphicsOnNowNextSeeded`, `GraphicsElementResponseModel.BuiltIn` · issues: #74, #568'
|
||||
---
|
||||
|
||||
#74 asked for a transient "On Now / Next" text bug burned onto the transcoded stream at each
|
||||
@@ -40,8 +40,22 @@ four joins (composite key `{ChannelId, GraphicsElementId}`), added via a dual-pr
|
||||
preset).
|
||||
- The API needed a way for the SPA to find the built-in element without a fragile name-match — the
|
||||
direct #67 lesson (`WatermarkResponseModel.imageSource`). `GraphicsElementResponseModel` gained a
|
||||
server-derived `BuiltIn` bool, computed by comparing the row's `Path` filename to
|
||||
`GraphicsElementDefaults.OnNowNextFileName` rather than trusting the element's editable `Name`.
|
||||
server-derived `BuiltIn` bool, computed from the row's `Path` rather than the element's editable
|
||||
`Name`. The test is `GraphicsElementDefaults.IsOnNowNext` — ordinal equality against the full
|
||||
seeded path, plus `Kind == Text`. #568 sharpened it from the original bare-filename comparison,
|
||||
which was folder-agnostic: a user element named exactly `on-now-next.yml` in another template
|
||||
folder also reported `builtIn:true`. `Kind` is inside the predicate rather than a filter each
|
||||
caller adds, because the seeder's lookup requires it: split across the two sites, an Image row at
|
||||
the seeded path was `builtIn:true` on the wire while the seeder refused to resolve it. Case sensitivity is kept, deliberately: the remedy #568 prescribes — the
|
||||
full seeded relative path — is exactly as case-sensitive as the filename match it replaces, and a
|
||||
case-INsensitive test would hand the built-in identity to a user element differing from the seeded
|
||||
path only in case. The built-in element is the exact file the seeder wrote, at the path it wrote it
|
||||
to. Every site applies that predicate **in memory**, never as a `Where` clause — including the
|
||||
seeder's own "does the row exist yet?" check, which calls `GetBuiltInElementId` rather than asking
|
||||
the question a second way. `GraphicsElement.Path` carries no explicit collation, so a SQL `Path ==`
|
||||
comparison answers case-sensitively under SQLite and normally case-INsensitively under MySQL: one
|
||||
site in SQL and another in memory disagree on MySQL alone, which the SQLite suite cannot show. See
|
||||
`graphics.on-now-next-on-by-default` and `docs/graphics-elements.md`.
|
||||
- The channel editor's Branding-tab "Show On Now / Next overlay" switch follows the exact pattern
|
||||
of the existing logo-bug toggle: on adds the built-in element's id to `graphicsElementIds`, off
|
||||
removes it; disabled (with an explanatory caption) when the channel is HLS-Direct.
|
||||
|
||||
@@ -60,8 +60,10 @@ is simply absent, indistinguishable from never having enabled it — so the one-
|
||||
re-attaches it. That is inherent to "enable it on all channels by default" rather than a defect. The
|
||||
never-re-attach guarantee therefore holds *from the marker onwards*, not across the upgrade boundary.
|
||||
|
||||
Identity is the element's **filename** (`GraphicsElementDefaults.OnNowNextFileName`), never the
|
||||
user-editable `Name` — the #67 lesson carried through #74.
|
||||
Identity is the element's **full seeded path and `Kind == Text`** — `GraphicsElementDefaults.IsOnNowNext`,
|
||||
ordinal equality against `OnNowNextSeededPath` — never the user-editable `Name`: the #67 lesson carried
|
||||
through #74, sharpened from a bare filename to the full path by #568 because a filename-only match
|
||||
was folder-agnostic (see `graphics.channel-level-attachment` and `docs/graphics-elements.md`).
|
||||
|
||||
**HLS Direct is excluded at both sites.** ErsatzTV is not transcoding there, so `GraphicsElementSelector`
|
||||
returns empty and the editor disables the toggle; an attachment would be inert while still reading as
|
||||
|
||||
@@ -5,9 +5,9 @@ status: active
|
||||
since: '2026-08-30'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'A hook resolves every path whose CONTENT it sources, executes, or consults to decide, from `$repo_root` — derived from the hook''s own `${BASH_SOURCE[0]}` — and never from `$CLAUDE_PROJECT_DIR` or any other environment variable. There is NO telemetry exemption, and the attempt to write one is instructive: the first draft of this record exempted `ETV_HOOK_FIRE_LIB` on the grounds that "a wrong log destination is not a wrong verdict", and cold review refuted it by execution — that path is `. `-SOURCED, so whatever it names runs as CODE inside the hook before stdin is read and before `decide` exists; a file there that prints an `allow` decision and exits 0 grants the merge having bypassed every check in the file. A path''s PURPOSE does not bound its authority; how the hook consumes it does. The failure that matters is not an attacker — the same variable already names the hook binary in `.claude/settings.json`, so a hostile value has chosen which hook runs and the gate is moot before any inner path is read — it is LAUNCHER DIVERGENCE, and it is reachable: husky invokes the prepush hooks as `./.claude/hooks/…`, a relative path wholly independent of `$CLAUDE_PROJECT_DIR`, so the two roots genuinely disagree there. A wrong path holding a plausible file returns a confident answer about another tree; a missing path only asks, so the silent direction is the dangerous one. Two halves of one comparison must never come from two roots.'
|
||||
rule: 'A hook resolves every path whose CONTENT it sources, executes, or consults to decide, from `$repo_root` — derived from the hook''s own `${BASH_SOURCE[0]}` — and never from `$CLAUDE_PROJECT_DIR` or any other environment variable. There is NO telemetry exemption, and the attempt to write one is instructive: the first draft of this record exempted `ETV_HOOK_FIRE_LIB` on the grounds that "a wrong log destination is not a wrong verdict", and cold review refuted it by execution — that path is `. `-SOURCED, so whatever it names runs as CODE inside the hook before stdin is read and before `decide` exists; a file there that prints an `allow` decision and exits 0 grants the merge having bypassed every check in the file. A path''s PURPOSE does not bound its authority; how the hook consumes it does. THE SAME TEST DRAWS THE OTHER BOUNDARY, and it is not optional: a path the hook consumes as its SUBJECT — the tree it is asked to JUDGE — is caller-supplied by design, and binding it to `$repo_root` would break the guard, since the hook lives in one tree and must judge whichever worktree is being acted in. Two live cases, both deliberate: `pretooluse-bom-guard.sh` takes the committed tree from the command''s `cd`, then the payload `cwd`, then `${CLAUDE_PROJECT_DIR:-$PWD}` as a last-resort default; and `decisions-guard.sh` `cd`s to `git rev-parse --show-toplevel` and EXECUTES that tree''s `scripts/decisions_validate.py`, whose exit code is the hook''s decision — the stronger case, since it is code and not data, and it is admissible for the same reason: a pre-commit validator must validate the tree being committed, and under husky, its only launcher, git sets the cwd to that tree so the two roots agree anyway. The distinction is not first-two-sources-versus-third and not data-versus-code: it is WHOSE QUESTION the path answers. Ban the variable in the assignment naming the AUTHORITY — the code that decides — never every mention of it in a hook. The failure that matters is not an attacker — the same variable already names the hook binary in `.claude/settings.json`, so a hostile value has chosen which hook runs and the gate is moot before any inner path is read — it is LAUNCHER DIVERGENCE, and it is reachable: husky invokes the prepush hooks as `./.claude/hooks/…`, a relative path wholly independent of `$CLAUDE_PROJECT_DIR`, so the two roots genuinely disagree there. A wrong path holding a plausible file returns a confident answer about another tree; a missing path only asks, so the silent direction is the dangerous one. Two halves of one comparison must never come from two roots.'
|
||||
signals: 'env var as a security input · `$CLAUDE_PROJECT_DIR` vs `$repo_root` · `${BASH_SOURCE[0]}` self-location · sourced file is code not configuration · which checkout''s code decided this · launcher divergence · husky relative path vs harness env · a wrong path returns a confident answer, a missing one asks · both halves of a comparison from one root · paths: `.claude/hooks/pretooluse-merge-consent.sh`, `scripts/hook-fire-log.sh`, `scripts/check-review-verdict.sh`, `scripts/check-required-contexts.sh` · issues: #858, #891, #787, #629'
|
||||
mechanics: 'No linter can spot "this path should have come from `$repo_root`". Enforcement is review plus a behavioural test per site: point `$CLAUDE_PROJECT_DIR` at a decoy tree whose copy of the resolved file returns the OPPOSITE decision, and assert the outcome is unchanged. That test needs a NEGATIVE CONTROL or it proves nothing — the passing outcome is also what an inert decoy produces — so run the same decoy again with the hook COPIED INTO it, making the decoy genuinely `$repo_root`, and assert the decision DOES flip. Two worked pairs live in `test_merge_consent_required_check.py`: `test_CLAUDE_PROJECT_DIR_cannot_choose_which_verdict_classifier_runs` and `test_the_SOURCED_fire_log_library_cannot_be_chosen_by_CLAUDE_PROJECT_DIR`, each beside its `..._IS_honoured/sourced_when_it_genuinely_is_the_repo_root` control.'
|
||||
mechanics: 'No linter can spot "this path should have come from `$repo_root`". Enforcement is review plus a behavioural test per site: point `$CLAUDE_PROJECT_DIR` at a decoy tree whose copy of the resolved file returns the OPPOSITE decision, and assert the outcome is unchanged. That test needs a NEGATIVE CONTROL or it proves nothing — the passing outcome is also what an inert decoy produces — so run the same decoy again with the hook COPIED INTO it, making the decoy genuinely `$repo_root`, and assert the decision DOES flip. Since #891 that test is PARAMETRIZED OVER THE DERIVED HOOK POPULATION in `test_hook_fire_log.py` — `test_CLAUDE_PROJECT_DIR_cannot_choose_the_SOURCED_sink` beside `test_the_sink_decoy_IS_sourced_when_it_genuinely_is_the_repo_root`, and the husky RELATIVE launch as its own pair, since that construct resolves through the CWD — so a hook added tomorrow arrives covered rather than needing someone to remember it. The STATIC half is not a pattern over the line but BYTE-IDENTITY of the preamble''s two lines (a lexical rule was tried and withdrawn; see the body), proved by `test_a_LATER_reassignment_the_regex_cannot_see_is_DETECTED` for the shape only it catches, with `test_an_ENV_VAR_resolved_sink_path_is_DETECTED` and `test_the_NEXT_env_var_to_be_invented_is_DETECTED` pinning the diagnostic arms, each asserting its OWN arm''s phrase — repair for `test_an_ENV_VAR_resolved_sink_path_is_DETECTED`, whose assertion the byte-identity message''s echo of the offending line did satisfy by itself, and prudence for the others. The sink''s own root resolution additionally requires the root to OWN it, proved by `test_the_report_REFUSES_a_root_that_does_not_OWN_this_sink`. Two worked single-site pairs live in `test_merge_consent_required_check.py`: `test_CLAUDE_PROJECT_DIR_cannot_choose_which_verdict_classifier_runs` and `test_the_SOURCED_fire_log_library_cannot_be_chosen_by_CLAUDE_PROJECT_DIR`, each beside its `..._IS_honoured/sourced_when_it_genuinely_is_the_repo_root` control.'
|
||||
---
|
||||
|
||||
`.claude/hooks/pretooluse-merge-consent.sh` resolved three decision-bearing paths from two different
|
||||
@@ -40,7 +40,40 @@ tree's code into a gate that can block or allow the push. That is ordinary, non-
|
||||
produces a confident wrong answer rather than a visible failure.
|
||||
|
||||
**Scope actually shipped, so nobody reads more into it.** #858 fixed the verdict classifier and the
|
||||
fire-log resolution **in `pretooluse-merge-consent.sh` only**. The other twelve tracked hooks still
|
||||
carry the env-var-first fire-log line; sweeping them needs its own derived population and its own
|
||||
review, and is #891. One copy was fixed because leaving a total gate bypass above the gate that PR
|
||||
was hardening would have made the rest of it decorative — not because one hook is special.
|
||||
fire-log resolution in `pretooluse-merge-consent.sh` only — because leaving a total gate bypass above
|
||||
the gate that PR was hardening would have made the rest of it decorative, not because one hook is
|
||||
special. #891 then swept the other twelve tracked hooks **together, in one change**, and reconciled
|
||||
the second resolution inside `scripts/hook-fire-log.sh` itself: its report preferred
|
||||
`$CLAUDE_PROJECT_DIR` when deriving the hook POPULATION, which reads as harmless because a report
|
||||
decides nothing — but the report's two halves ARE that population and the log, so resolving them
|
||||
from two roots earns a NEVER-FIRED row for a hook that fired. Every tracked hook now carries that
|
||||
assignment byte-identically. The twelve above is a dated fact about what #891 swept, not an
|
||||
invariant: the INVARIANT is "every tracked hook", and no count of it is pinned anywhere, because the
|
||||
population is derived from `git ls-files` by the guard — a new hook is covered the moment it exists
|
||||
rather than when someone remembers to update a number. (That derivation is the GUARD's. `etv_hook_fire_report`'s own
|
||||
population is a filesystem glob over the tree it roots at — a report, not a gate — so the two are
|
||||
not the same population and should not be cited as one.)
|
||||
|
||||
**Byte-identical is the property, not an aesthetic.** Diverging one copy is how this repo acquired
|
||||
the defect that extracting `scripts/lib/branch-rule-classifier.jq` was meant to end, so the sweep
|
||||
was all-or-nothing, and the check is a comparison over the derived population rather than a comment
|
||||
in each hook — one machine-checked rule beats a paragraph repeated once per hook and drifting in all
|
||||
but one of them.
|
||||
|
||||
**The check pins BYTE-IDENTITY, and arriving there took a withdrawal.** The obvious rule is lexical
|
||||
— ban `$CLAUDE_PROJECT_DIR` in the assignment — and it is worth exactly the one name in it. Three
|
||||
review rounds walked the successors: `${NEXT_VAR:-$(… BASH_SOURCE …)}` satisfies a containment test
|
||||
while the variable still wins; then backticks and `$((…))`; then `$(printenv VAR)`, `$1`, `$?`, an
|
||||
INDENTED or `export`ed reassignment further down that a column-anchored regex cannot see, and `$'…'`
|
||||
quoting that makes the required token literal. Each fix admitted the next shape, which is this
|
||||
repo's recorded signal to stop rather than patch again — a guard was WITHDRAWN from that same test
|
||||
file after four iterations of pattern-matching shell source. A shell assignment is not recognisable
|
||||
by regex, so the guard stopped trying: every non-comment line mentioning the variable must be one of
|
||||
the two canonical lines, byte for byte. The lexical arms over the preamble survive only to say WHY a
|
||||
line is wrong — that includes the pattern matching the sourcing line, which byte-identity
|
||||
necessarily matches and which therefore decides nothing on its own. The arms judging other things —
|
||||
a self-disabled hook, and the begin call's PRESENCE, name, mode and ordering — are not subsumed and
|
||||
still decide. Each is pinned by its own test asserting its own fault message, so this sentence is a
|
||||
map and not the guard: deleting an arm on the strength of this list reddens the suite.
|
||||
The cost is that a future rewrite of the line must edit one constant and all the hooks together,
|
||||
which is the property #891 exists to hold.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
key: process.orchestrated-session
|
||||
title: 2026-09-04 — An orchestrated session runs several issue slots under one referee, and each slot is still a whole kickoff session (#907)
|
||||
status: active
|
||||
since: '2026-09-04'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: A session may run several issues at once only as an orchestrated session — an orchestrator that never picks, claims, codes or pushes; one worktree per issue under `~/orca/workspaces/ersatztv/`; the local gate and cold review inside the worktree BEFORE the single push; the referee alone ticks `## Done-when` boxes and posts the verdict. Two scopings hold across every slot — a rebase onto `origin/main` pushed with `--force-with-lease` is the ONE sanctioned rewrite of a pushed branch (the commits are unchanged, only the base moved; H11 refuses the alternative and merging main in is forbidden), and it voids the verdict, so the rebased head is re-reviewed. Live-E2E needs no machine-wide lock — the launcher's conflict is its per-worktree `wwwroot`, so slots run on distinct ports and the launcher's pre-flight refuses a busy one.
|
||||
signals: 'orchestrator · referee · slots · parallel issues · worktree per issue · force-with-lease · rebase voids verdict · who ticks Done-when · E2E per worktree port · paths: `docs/handoffs/orchestration.md`, `docs/handoffs/orchestrator-prompt.md`, `.claude/workflows/ersatztv-issue-build.js` · issues: #907'
|
||||
mechanics: '`docs/handoffs/orchestration.md` owns roles, isolation and the landing order; the Workflow scripts under `.claude/workflows/` encode it. Box-ticking: the implementer writes the evidence per box into the `## Closing record`; the orchestrator ticks after reading it and the review evidence, then `scripts/post-review-verdict.sh`.'
|
||||
---
|
||||
|
||||
The kickoff (`docs/handoffs/chicorytv-issue-queue.md`) binds a session that closes one issue.
|
||||
Running several at once does not relax any of it; what it adds is a referee, and two places where
|
||||
the single-session rules needed a stated scope rather than a contradiction.
|
||||
|
||||
**Force-with-lease.** `process.pr-routine-sequence` forbids amending or force-pushing a pushed branch;
|
||||
`release.format-as-you-touch-rebase` (H11) refuses to push a branch behind `origin/main` and forbids
|
||||
merging main in. With several slots merging, a pushed branch is behind main as the normal case, so the
|
||||
only path that satisfies both is a rebase whose commits are byte-for-byte the reviewed ones, pushed
|
||||
with `--force-with-lease`. That is not the rewrite the first rule targets, which is a fix folded into
|
||||
an already-pushed commit. The cost is the verdict: `review-verdict/h10` binds to a sha, so the rebased
|
||||
head is reviewed again before a new verdict is posted. Rebase once, right after the review loop, and
|
||||
`git patch-id --stable` before and after proves the patch did not change.
|
||||
|
||||
**Who ticks the boxes.** `release.merge-consent-autogrant` derives consent from the ticked boxes, so a
|
||||
box ticked by the agent whose work it certifies is self-consent. The implementer supplies the evidence
|
||||
per box; the referee ticks.
|
||||
|
||||
**No E2E lock.** A `mkdir` lock around `scripts/e2e-local.sh` was tried and rejected 2026-09-04: the
|
||||
script is a launcher that returns with the server still running, so the lock released before the
|
||||
assertions ran, and its stale-holder path double-acquired in 4 of 91 measured races. The conflict the
|
||||
launcher documents is its `wwwroot` under the repo root, which is per worktree; two worktrees on two
|
||||
ports do not share it.
|
||||
@@ -5,9 +5,9 @@ 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.'
|
||||
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 governed by `tag_protections`, an entirely separate mechanism that does NOT inherit from this rule; since #885 it carries one entry, `v*` whitelisted to `timothy` (`release.tag-protection-v-star`), which is intended to leave the release cut working for the operator while refusing a `v*` push by the `renovate` bot that would publish a `:prod` image. Both halves are EXPECTED, UNVERIFIED: only the `timothy` credential exists here, so neither a real release cut nor a refused bot push has been exercised, and that record carries the same caveat.'
|
||||
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`. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout. MEASURED 2026-08-28 on 1.27.1 (#747), superseding the earlier source-attested-only note on this field. Four SEPARATE throwaway scratch bases, each with its own protection rule, head branch and PR (they differ by construction, not by mutating one rule in place — arm A merged, so its PR could not be re-used); `main` was never merge-probed and its rule never PATCHed, `updated_at` unchanged at 2026-08-05. Each base required one probe-named context that was never posted, except in the control step below. `scratch/747-base` (#863), field at its `false` default, ORDINARY merge: REFUSED HTTP 405 `Not all required status checks successful`; posting only that context as `success` and repeating the ordinary merge on the same PR returned HTTP 200 — the one attribution control in the set, and the only variable between those two calls is the context. `scratch/747a-base` (#864), field `false`, admin `force_merge`: MERGED HTTP 200. `scratch/747b-base` (#865), rule identical but for the field set `true`, same call: REFUSED HTTP 405 — the A/B pair is the evidence that the field governs the force path; B''s 405 has no control of its own. `scratch/747c-base` (#866), rule as A but with `enable_bypass_allowlist: true` and an EMPTY `bypass_allowlist_usernames`: MERGED HTTP 200, so in THAT configuration the allowlist does not substitute for `block_admin_merge_override`; a non-empty list was not tested, and this cannot distinguish `does not govern admins` from `empty list treated as unconfigured`. Probing never required merging an unreviewed PR into `main`: a scratch BASE branch carries its own rule, so the whole experiment is disposable. All #747 probe artifacts (four scratch bases — `scratch/747-base` for the ordinary-merge pair plus `scratch/747{a,b,c}-base` for the three force-merge arms — each with its own protection rule and head branch, so four rules and eight branches, and PRs #863-#866) deleted and confirmed gone; `origin/main` head unchanged at `b16ec15d6` throughout, and `main`''s own rule was never PATCHed.'
|
||||
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` returned `[]` at that probe; since #885 it carries one `v*` rule whitelisted to `timothy` (`release.tag-protection-v-star`). Repo is `fork: false`, `mirror: false`. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout. MEASURED 2026-08-28 on 1.27.1 (#747), superseding the earlier source-attested-only note on this field. Four SEPARATE throwaway scratch bases, each with its own protection rule, head branch and PR (they differ by construction, not by mutating one rule in place — arm A merged, so its PR could not be re-used); `main` was never merge-probed and its rule never PATCHed, `updated_at` unchanged at 2026-08-05. Each base required one probe-named context that was never posted, except in the control step below. `scratch/747-base` (#863), field at its `false` default, ORDINARY merge: REFUSED HTTP 405 `Not all required status checks successful`; posting only that context as `success` and repeating the ordinary merge on the same PR returned HTTP 200 — the one attribution control in the set, and the only variable between those two calls is the context. `scratch/747a-base` (#864), field `false`, admin `force_merge`: MERGED HTTP 200. `scratch/747b-base` (#865), rule identical but for the field set `true`, same call: REFUSED HTTP 405 — the A/B pair is the evidence that the field governs the force path; B''s 405 has no control of its own. `scratch/747c-base` (#866), rule as A but with `enable_bypass_allowlist: true` and an EMPTY `bypass_allowlist_usernames`: MERGED HTTP 200, so in THAT configuration the allowlist does not substitute for `block_admin_merge_override`; a non-empty list was not tested, and this cannot distinguish `does not govern admins` from `empty list treated as unconfigured`. Probing never required merging an unreviewed PR into `main`: a scratch BASE branch carries its own rule, so the whole experiment is disposable. All #747 probe artifacts (four scratch bases — `scratch/747-base` for the ordinary-merge pair plus `scratch/747{a,b,c}-base` for the three force-merge arms — each with its own protection rule and head branch, so four rules and eight branches, and PRs #863-#866) deleted and confirmed gone; `origin/main` head unchanged at `b16ec15d6` throughout, and `main`''s own rule was never PATCHed.'
|
||||
---
|
||||
|
||||
**Why a whitelist was the wrong shape.** #743 proposed "a push whitelist on `main` (or disable direct
|
||||
@@ -63,8 +63,21 @@ forced one. `main` carries the field `true`, which is what closes the force path
|
||||
differs from row A's only in `enable_bypass_allowlist`, and its PR merged just the same — so enabling it did not substitute
|
||||
for `block_admin_merge_override`. A NON-empty username list was not tested, and this cannot distinguish
|
||||
"the allowlist does not govern admins" from "an empty list is treated as unconfigured". Enough to
|
||||
refuse the swap; not enough to describe the field's semantics. The claim that it postdates 1.25.4 comes
|
||||
from #747's issue body, not from a probe here.
|
||||
refuse the swap; not enough to describe the field's semantics.
|
||||
|
||||
**That `enable_bypass_allowlist` postdates 1.25.4 was an INHERITED claim and is now a checked one
|
||||
(#869, 2026-09-02).** It came from #747's issue body rather than from any probe, which is the shape
|
||||
this repo keeps getting burned by — a claim whose only provenance is another artifact's prose. Read
|
||||
off `modules/structs/repo_branch.go` at both tags: `EnableBypassAllowlist` does not occur anywhere in
|
||||
the file at `v1.25.4` and is present at `v1.27.1`, where this instance also returns
|
||||
`enable_bypass_allowlist` in a live branch-protection read. So the claim holds, and the reason it is
|
||||
believed is now the source rather than a sentence someone typed into an issue.
|
||||
|
||||
Note the neighbouring field does NOT share that history, which is worth stating because the paragraph
|
||||
above could invite the inference: `BlockAdminMergeOverride` / `block_admin_merge_override` is present
|
||||
at `v1.25.4` too — in all three structs (`BranchProtection`, `CreateBranchProtectionOption`,
|
||||
`EditBranchProtectionOption`). It is not a 1.27 addition, and nothing here should be read as saying it
|
||||
is. What was new in this repo was our USE of it, not the field.
|
||||
|
||||
**Trap: `mergeable` does not answer the gate question.** On PR #863 — row 1, the ordinary-merge arm —
|
||||
the API reported `mergeable: true` at the moment that merge was being refused 405, so there it did not
|
||||
@@ -96,11 +109,13 @@ mirror (not applicable: `fork: false`, `mirror: false`). Merge remains the one i
|
||||
|
||||
**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.
|
||||
`refs/heads/main`; tags are governed by an entirely separate mechanism. `tag_protections` was empty
|
||||
when this record was written (measured 2026-08-24), so tag pushes were 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 the tag-protection rule #885 later added
|
||||
(`release.tag-protection-v-star`, `v*` whitelisted to `timothy`) does not inherit from this one in
|
||||
either direction.
|
||||
|
||||
**The `docker-build.yml` `persist-credentials` question (#743's fourth box), since resolved in #746.**
|
||||
Head-resolved jobs kept a write-capable credential in `.git/config` because their `actions/checkout`
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
key: release.tag-protection-v-star
|
||||
title: '2026-09-04 — `v*` tag pushes are whitelisted to the release operator, closing the tag-push route against the bot (#885)'
|
||||
status: active
|
||||
since: '2026-09-04'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'The repository carries one tag-protection rule: `name_pattern: v*`, `whitelist_usernames: ["timothy"]`. This matters because `docker-build.yml` triggers on `push.tags: [v*]` and a `v*` tag push BUILDS AND PUBLISHES the `:prod` image — the tag the `jazz-media` Komodo stack follows. `release.main-direct-push-disabled` closed every route to `main`, and left this one open: a tag push is a separate mechanism that branch protection does not govern, so it was the cheapest remaining single act that reaches production. It buys NOTHING against the admin actor (`timothy` is on the whitelist, and no self-administered control bounds an owner) and everything it buys is against `renovate`, the repo''s other write-capable account (`permission: write`, `is_admin: false` from `GET /repos/timothy/ersatztv/collaborators/renovate/permission`, measured in #853 2026-08-30 and re-read 2026-09-04): the H10 verdict gate and the Renovate exemption rule bound what that bot can get MERGED, and nothing bounded what it could TAG. The rule''s failure mode is a BROKEN RELEASE CUT, so it is verified in both directions before it is trusted: the negative half is done and recorded below; the POSITIVE half — a real `v*` release-cut tag push by `timothy` still succeeding — is DEFERRED to the operator''s next real cut, because pushing a `v*` tag from any working session publishes `:prod` and a release is the operator''s act, not a verification step. Treat that as OPEN until a cut lands. There is no committed mirror of this rule: it lives in Gitea, and this record''s `mechanics:` carries the exact call and the read-back, the same shape `release.main-direct-push-disabled` uses.'
|
||||
signals: 'tag_protections empty, v* tag push publishes prod, release cut blocked, renovate can push a tag, tag protection whitelist, release operator whitelist, prod image published by tag, deferred positive verification · paths: `.gitea/workflows/docker-build.yml` · issues: #885, #853, #743, #698, #742'
|
||||
mechanics: 'Applied 2026-09-04 with `POST /api/v1/repos/timothy/ersatztv/tag_protections` body `{"name_pattern": "v*", "whitelist_usernames": ["timothy"]}`, which returned `{"id": 1, "name_pattern": "v*", "whitelist_usernames": ["timothy"], "whitelist_teams": [], "created_at": "2026-09-04T23:18:50+02:00"}`. Read back the same day with `GET .../tag_protections` -> the one rule, fields as echoed. It is reversible with `DELETE .../tag_protections/1`. NOTE THE ROUTE NAME: `/repos/{owner}/{repo}/tags/protection` is NOT it — measured 2026-09-04, that path resolves `protection` as a TAG NAME and answers 404 `{"message": "tag doesn''t exist: protection"}`. Before this the endpoint returned `[]` (measured #743 2026-08-24 and again 2026-09-04). NEGATIVE VERIFICATION available from this session and performed: a NON-`v*` tag (`probe-885-tagpush`) pushed by `timothy` succeeded and was deleted, confirmed gone by `git ls-remote --tags` — so tag pushes still work at all and the rule is not mis-scoped to `*`. NOT VERIFIED, and it cannot be from a working session: that a real `v*` release-cut push still succeeds, and that a push by `renovate` is refused (only the `timothy` credential is available here). The first is the operator''s next cut; the second would require the bot''s credential.'
|
||||
---
|
||||
|
||||
**What this closes.** `release.main-direct-push-disabled` made every change to `main` go through a PR
|
||||
with a sha-bound review verdict, and said in the same breath that "tag pushes are unaffected
|
||||
(separate mechanism)". That sentence was accurate and was also the gap: `docker-build.yml` publishes
|
||||
`:prod` on a `v*` tag, so one tag push reaches the image prod's stack follows, with no PR, no review
|
||||
verdict and no merge gate anywhere in the path. `tag_protections` was empty, so nothing stood in it.
|
||||
|
||||
**What it does not close, said plainly.** Against `timothy` it is decoration: the whitelist contains
|
||||
that account, and an owner can delete the rule with one API call anyway. No self-administered control
|
||||
is a boundary against the actor who administers it. The rule is worth having because the actor set
|
||||
has a *second* member — `renovate` holds repository write, opens PRs on its own, and is bounded on
|
||||
the merge path by `review-verdict/h10` and the exemption rule (#698, #742, #845) while being
|
||||
completely unbounded on the tag path. This makes the two paths agree.
|
||||
|
||||
**Why it was split out of #853 rather than bundled.** Its failure mode is not a leaked credential, it
|
||||
is a release cut that will not push — a failure that surfaces at the worst moment, when someone is
|
||||
trying to ship. That earns its own change and its own verification, which is why the positive half is
|
||||
recorded above as deferred rather than quietly assumed. A rule whose only failure mode is untested is
|
||||
a rule that gets deleted in a hurry by whoever hits it first.
|
||||
|
||||
**How to unblock a release cut that this rule breaks.** `DELETE /api/v1/repos/timothy/ersatztv/tag_protections/1`,
|
||||
push the tag, re-`POST` the rule. Prefer adding the pushing account to `whitelist_usernames` with a
|
||||
`PATCH` if the cut is moving to a different operator, so the protection is never left off.
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
key: spa.dismissible-write-failure-reporting
|
||||
title: '2026-08-29 — A write failure reports to a surface that OUTLIVES the dismissible surface that started it (#830)'
|
||||
status: active
|
||||
since: '2026-08-29'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'A component that starts an async WRITE from inside a dismissible surface (`Dialog`, `SlideOver`, `ConfirmDialog`) must report failure through `useDismissSafeError` (`web/src/hooks.ts`), which renders the message INLINE while the surface is mounted and hands it to a caller-supplied `onFailed` once the surface is gone. Guarding the `setError` with an is-mounted check and stopping there is NOT sufficient: it converts a silent data-loss risk into a silent NO-OP the user reads as success. All three primitives dismiss through Escape and a backdrop/scrim click (`useOverlayBehavior`) plus a header close button, and NONE of those consult a busy flag — disabling the footer Cancel button, which every one of these dialogs does, looks like it closes the hole and does not. Report the OUTCOME past dismissal in both directions — a success callback gated on an is-mounted check makes a completed write silent too (measured on `AddToCollectionDialog`, #877) — but gate the DISMISS request (`onClose`) separately, because closing a surface that is no longer yours closes whatever replaced it. The surviving surface belongs to the PARENT, so the mechanism is a PROP CONTRACT rather than a rendering decision; as of 2026-08-29 exactly ONE screen is wired (`CollectionsScreen`, into its screen-level `role="alert"` banner), and the `notice`+`Toast` pair on `MediaBrowseScreen`/`SearchScreen` is a CANDIDATE second surface, not a wired one; there is no global toast host in this SPA and this decision does not add one. The reporting prop is REQUIRED where the host has a surface (`AddItemsDialog.onAddFailed`), so a failure cannot be dropped by forgetting to wire it; it is optional only where some host genuinely has nowhere to report, and there an omitted callback drops the failure exactly as before — a KNOWN remaining gap, not a claim of coverage.'
|
||||
signals: 'failed add silently swallowed · dialog closed mid-request · Escape backdrop close button ignore adding flag · error banner unmounts with the dialog · useDismissSafeError inline vs onFailed · success outlives dismissal but failure does not · onDone has no failure counterpart · paths: `web/src/hooks.ts`, `web/src/screens/CollectionsScreen.tsx`, `web/src/media/addTo/`, `web/src/components/overlay.tsx` · issues: #830, #877, #740, #685'
|
||||
mechanics: 'Pinned five ways. (1) `web/src/hooks.test.tsx` → "useDismissSafeError (#830)" pins both branches directly: mounted reports INLINE and does not call `onFailed`, unmounted calls `onFailed`, and the report goes through the LATEST callback rather than the one captured on first render. (2) `CollectionsScreen.test.tsx` → "reports a failed add on the screen when the dialog was dismissed before the request settled (#830)" drives the whole path — it parks the POST in flight, dismisses via Escape, then settles the request and asserts the message is on the screen and NOT inside a dialog. Executed: deleting `reportRef.current(message)` alone reddens it on `Unable to find an element with the text: Request failed with status 500`. That one clause is shared, so the same mutation reddens THREE tests (this one plus the two divert tests in (1)) — expect three reds, not one, when re-running it. (3) `CollectionsScreen.test.tsx` → "a late SUCCESS does not close the dialog the user reopened after dismissing (#830)" pins the OTHER half of the split: it dismisses mid-request, reopens the picker, then settles the request 204, and asserts the reopened dialog is still there — with an anti-vacuity check that the late response was actually processed (`onAdded` is `load`, so a second GET of the items endpoint must have happened), because otherwise "the dialog is still open" holds trivially. Executed: deleting the `if (mountedRef.current)` around `onClose()` reddens it alone. The POSITIVE direction is pinned separately, in the Song add test, because the negative one does not cover it: review measured that deleting the `onClose()` call ENTIRELY — so an ordinary successful add never closes the picker — left the whole suite green, so that test now asserts the dialog closes. (4) `CollectionsScreen.guards.test.tsx` counts is-mounted reads and moved from 2 to 1 when the catch''s guard migrated into the hook; its `...actual` module mock cannot see the hook''s internal `useIsMountedRef()`, which is why (1) exists. Executed: removing the surviving `finally` guard takes that count to 0 and reddens. (5) `CollectionsScreen.test.tsx` → "renders a failed add INSIDE the dialog while it is still open (#830)" pins which ARM the call site reaches, which (1) cannot: (1) proves the hook HAS an inline branch, not that this screen calls into it. It fails the POST with the dialog still up and asserts the message sits inside `[role="dialog"]` and appears exactly once in the tree. Executed 2026-09-04: swapping `reportFailure(...)` for `onAddFailed(...)` in the catch — the divert-while-open shape this record names as its own defect — reddens that test and NOTHING else (1 failed / 490 passed across the 42 `src/screens` files).'
|
||||
---
|
||||
|
||||
**This record applies to ONE site, and the reason the rest were dropped is the useful part.**
|
||||
An earlier draft claimed SUCCESS already outlives dismissal "at every one of these sites", reasoning
|
||||
from `AddItemsDialog` — which does report success past dismissal, and says so in a comment — and
|
||||
generalising to the four `web/src/media/addTo/` dialogs without reading them. Adversarial review
|
||||
probed `AddToCollectionDialog` and MEASURED `onAdded` called 0 times after dismissal; the other
|
||||
three carry a visibly identical gate (read, not probed). So those four gate BOTH halves of the
|
||||
outcome behind their own `activeRef`, and dismiss-then-succeed is as silent there as
|
||||
dismiss-then-fail.
|
||||
|
||||
Extending the mechanism to them was attempted and WITHDRAWN, which is why they are #877 and not this
|
||||
record. Removing that gate reports success correctly but also un-gates `onClose()`, and the two mean
|
||||
different things: `onAdded` is "tell the parent what happened", `onClose` is "close me" — addressed
|
||||
to a surface that no longer exists. Measured against the real `AddToMenu`: a late success from a
|
||||
DISMISSED dialog closed a dialog the user had since reopened. Read, not measured: the success
|
||||
handlers on `SearchScreen` / `MediaBrowseScreen` also call `clearSelection()` unconditionally, so
|
||||
the same late success would wipe a multi-select the user had rebuilt. Un-gating both is wrong,
|
||||
gating both is wrong, and gating only `onClose` still
|
||||
needs the parents to stop nulling the dialog themselves — three coupled edits across five files,
|
||||
plus an unresolved question about whether `clearSelection()` should fire for a write the user walked
|
||||
away from. That is a design decision, not a bug fix, so it goes to #877 with the measurements
|
||||
attached rather than riding along here.
|
||||
|
||||
`AddToMenu` also has `onDone` and no failure counterpart at all, which is why a failed "Add to
|
||||
collection" from a media card reports nowhere. Same issue.
|
||||
|
||||
**The outcome and the dismissal are different callbacks, and the exemplar proves it.** Review
|
||||
measured the cost of conflating them twice: gating both makes a completed write silent, un-gating
|
||||
both makes a late success close the dialog the user reopened to retry. `AddItemsDialog` now does
|
||||
each correctly — `onAdded()` unguarded, because it reloads the parent's list and that must survive;
|
||||
`onClose()` guarded, because after dismissal it is `setPickerOpen(false)` aimed at whatever the user
|
||||
opened next. That split is only this simple where the parent has no competing closer: here
|
||||
`onAdded` is `load`, which never touches `pickerOpen`. In the `media/addTo/` layer
|
||||
`AddToMenu.handleAdded` closes the dialog itself, so the same one-line gate is NOT sufficient there
|
||||
— measured — which is the concrete reason that half is #877 and not this record.
|
||||
|
||||
**Why the inline branch is kept rather than always reporting to the parent.** While the dialog is
|
||||
up, inline is the better surface: it keeps the user's selections and the context they are looking
|
||||
at. Diverting to a parent banner in that case would be its own defect — the message would surface
|
||||
somewhere else while the dialog the user is staring at stays blank. Behind the dialog, in fact:
|
||||
the panel is a `createPortal` with `aria-modal="true"`, so the screen banner is hidden from AT and
|
||||
covered for everyone else. Pinned rather than argued — mechanics (5).
|
||||
|
||||
**Why not simply gate dismissal on the busy flag.** That was considered and rejected: gating
|
||||
Escape/backdrop/close on `adding` traps the user behind an in-flight request with no cancel path,
|
||||
which is exactly why `Dialog` does not do it by default. The request is also genuinely still
|
||||
running — cancelling the UI would not cancel the write.
|
||||
|
||||
**#740 guarded this and did not fix it, correctly.** That issue added the is-mounted guard so the
|
||||
`setError` could not fire on an unmounted tree. That is right and stays. Its scope was the async
|
||||
guards, and it deliberately did not change the UX — which left the guard making the drop
|
||||
*deliberate* rather than accidental. A deliberate drop is still a user who believes twelve items
|
||||
were added when none were, and that is the half this record closes.
|
||||
|
||||
**Two limits of the mechanism, recorded rather than left to be rediscovered.** (1) `useIsMountedRef`
|
||||
clears its flag in a PASSIVE effect cleanup, so there is a narrow window in which the DOM node is
|
||||
detached but the flag still reads true — the message then renders inline into a dead tree instead of
|
||||
diverting. `useLayoutEffect` would close it deterministically, but that hook is shared by every
|
||||
async caller in the SPA (#578) and changing its timing is not something to do inside a bug fix;
|
||||
tracked with the rest in #877. (2) A limit the SECOND surface will have, stated now so it is
|
||||
not discovered when it is wired: `CollectionsScreen` reports into `role="alert"`, but the candidate
|
||||
surface on `MediaBrowseScreen`/`SearchScreen` is `Toast`, which is `role="status"` (polite) and a
|
||||
single last-writer-wins slot — so a failure diverted there would be announced less assertively and
|
||||
could be overwritten by a later success. Nothing diverts to those screens today; they receive no
|
||||
reporting callback.
|
||||
|
||||
**The hook has exactly one consumer, and that is worth saying.** `useDismissSafeError` is a shared
|
||||
module with a single call site today. It earns the shape three ways: it is unit-tested directly in
|
||||
`hooks.test.tsx`, in a way that would be awkward to keep if the hook were inlined into
|
||||
`CollectionsScreen`; §3c prescribes it for the next site; and #877 is a queued second consumer. The
|
||||
caveat:
|
||||
#877 may conclude that A2's right answer is a shared reporting SURFACE rather than a per-site prop,
|
||||
in which case this hook may never gain that second consumer and stays a one-off with a convention
|
||||
section attached. That is an accepted risk, not an oversight.
|
||||
|
||||
**Scope, stated rather than implied.** A sweep of all 68 `Dialog`/`ConfirmDialog`/`SlideOver` call
|
||||
sites (2026-08-29; 67 caller-owned plus `ConfirmDialog`'s own internal `<Dialog`) found three
|
||||
shapes: (A1) the surface hard-unmounts and takes its own error state
|
||||
with it — the shape fixed here, at `AddItemsDialog` ONLY — the four `web/src/media/addTo/` dialogs
|
||||
are the same shape and are NOT fixed (#877, see above);
|
||||
(A2) the error state lives in a parent that never unmounts, but the JSX rendering it is nested
|
||||
inside the same `open`/`target` condition dismissal just cleared, so it has no DOM node to render
|
||||
into — the majority pattern, mostly delete-confirm flows; (B) a genuinely surviving screen-level
|
||||
banner, which `DecoTemplatesScreen` and `BlocksScreen` already have. Only ONE A1 site is fixed here.
|
||||
A2 is
|
||||
LEFT OPEN deliberately: it is a larger, mostly-delete-confirm population whose right answer may be
|
||||
a shared reporting surface rather than a per-site prop, and folding it in would have made this PR a
|
||||
rewrite of twenty screens. It is tracked in #877, which carries the sweep's verified-vs-inferred
|
||||
split forward, rather than being recorded as done here.
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
key: testing.guard-pins-the-artifact-not-a-shape
|
||||
title: '2026-09-05 — a predicate over an artifact whose grammar it does not implement pins the artifact WHOLE; matching a shape inside it is an exception that must argue for itself (#901)'
|
||||
status: active
|
||||
since: '2026-09-05'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'A predicate that decides something by matching a SHAPE inside an artifact whose GRAMMAR it does not implement pins the artifact WHOLE instead — the whole file, or a whole named span, compared for equality under a STATED normalisation. The boundary is the grammar and never the file type: shell source, a workflow `run:` body, TOML, YAML, a jq expression and JSON5 all qualify, and a shape matched over the OUTPUT of a real parser for that grammar does not. Extracting a `run:` value with a real YAML parser and then matching shell inside it IS in the class, because the shell is the unparsed half. THE PIN IS THE DEFAULT FOR A NEW PREDICATE; REJECTED is the alternative of writing the shape-matcher and pinning only once it has been defeated. The defeat a remedy policy waits for is a false GREEN, so it is found by a reviewer or by an incident and never by the guard: "not defeated yet" is a statement about who has looked, not about the input space. A SHAPE-MATCHER IS THE EXCEPTION, and its argument carries FOUR things, written where the predicate is and re-read whenever a clause of it is widened. (1) The GRAMMAR named, and the parser that implements it — if the shape is matched over a real parse there is no exception to write. (2) The INPUT SPACE as a CLOSED enumeration with the reason it is closed: a generator whose output form is fixed, or a canonicaliser the guard itself runs. A list of the spellings the author thought of is not that reason, and is the thing that was defeated in all three sequences below. (3) The FAILURE DIRECTION measured rather than asserted: each defeat the matcher claims to catch is a DECLARED, executed mutation under `testing.mutation-claims-are-executed`, because a shape-matcher''s miss is a false green and an unrun claim about a fail direction is not evidence. (4) WHAT IT BUYS over the pin, priced in a cost the pin actually charges — the artifact is edited routinely or written by tooling, so a pin would redden as background noise and train its readers to ignore it; or the artifact''s population is DISCOVERED rather than fixed, so there is nothing to pin. Fewer than four is a refusal, and neither elegance nor an unwillingness to update a constant is one of the four. TWO OTHER BARS ARE REJECTED for this exception: a NUMERIC one ("survives N adversarial spellings"), which measures the reviewer''s imagination rather than the input space and would have licensed the parser above through most of those nine defects; and a REVIEWER SIGN-OFF, which makes the exception depend on the very signal the threshold below says arrives late. THE THRESHOLD for withdrawing a shape-matcher already shipped is not a count of spellings: it is the moment the NEXT spelling is found by the REVIEWER rather than by the author, which is evidence that the author''s model of the input space is not the input space. TWO RIDERS, both measured. A pin assumes it is pinning the artifact that still DECIDES — ask what else could decide this and whether the pin would still match: another FILE that outranks it, another OCCURRENCE in the same file, another WORKFLOW, or a HOOK the pinned command invokes. And WIDENING A CLAUSE RETIRES THE PROOFS CALIBRATED AGAINST THE NARROW ONE: a canary asserting that a finer mutation still SURVIVES has as its precondition that the guard is deliberately coarse there, so widening the main clause turns the canary into a tautology, which reads exactly like a passing proof — re-derive every proof calibrated against the narrow clause in the same commit that widens it. THE COST IS STATED RATHER THAN DISCOVERED LATER: any edit to a pinned artifact reddens, and the pin is updated deliberately in the same commit, with a reason. That red IS the review trigger the pin exists to be. A pin also declares its NORMALISATION and what the normalisation cannot see, because "pinned whole" invites a reader to assume byte equality.'
|
||||
signals: 'pin the artifact whole · shape-matcher is the exception that argues for itself · the boundary is the grammar not the file type · predicate does not implement the grammar it parses · defeated by the next spelling · the reviewer found the next spelling · false RED versus false GREEN · nine defects one mechanism · seven respellings of a partial config match · three successive lexical rules over a hook preamble · the four-part exception argument: grammar and parser, closed input space, measured fail direction, what it buys · authority moves where the pin is not looking · an outranking config file · a decoy occurrence · an npm lifecycle hook · widening a clause retires its own canary · a survived-clause canary becomes a tautology · a pin declares its normalisation · instances: `ci.image-build-delegates-the-spa-suite`, `process.hook-resolves-inputs-from-repo-root` · paths: `scripts/tests/test_image_build_delegates_the_spa_suite.py`, `scripts/tests/test_hook_fire_log.py`, `docs/guard-inventory.md`, `docs/defect-shapes-773.md` · issues: #901, #887, #891, #774, #790, #881, #773'
|
||||
mechanics: 'Two worked pins. `scripts/tests/test_image_build_delegates_the_spa_suite.py` pins command TEXT (`PINNED_STAGE_COMMANDS`), `web/package.json`''s script map (`PINNED_PACKAGE_SCRIPTS`) and `web/vite.config.ts` entire (`PINNED_VITE_CONFIG`), the last two after a SELECTION and then a partial match were defeated. The three normalise DIFFERENTLY, so a residual read off one of them NEED NOT transfer to the others — and one of them does transfer, because two share a primitive: the stage commands are compared after line continuations are joined and whitespace within each command is collapsed (`_normalise`), so a reflow is free and a line boundary is not; the script map is dict equality over parsed JSON, so the file''s own formatting and key ORDER are free while every script body is exact; and of the three only `PINNED_VITE_CONFIG` runs through `_normalise_lines`, which additionally drops blank lines. `_normalise_lines` IS `_normalise` applied per line, so the whitespace collapse — including inside a QUOTED STRING, where it can change meaning — belongs to both TEXT pins and not to the vite one alone; of the two only the vite test states it, in place of claiming byte equality, which is what the rule above asks of a pin. `scripts/tests/test_hook_fire_log.py` pins the hook preamble''s two lines byte for byte (`CANONICAL_SINK_ASSIGNMENT`, `CANONICAL_SINK_SOURCE`) — but its normalisation is a SELECTION, and the rule above obliges this record to say what that selection cannot see, because the pin itself does not. The compared set is the lines that contain the literal `ETV_HOOK_FIRE_LIB` and are not comment-led, so the pin reaches exactly the two preamble lines and nothing else in the file: a later reassignment of that variable which never spells the literal — one composed at runtime and `eval`ed, say — is outside the selection, and therefore outside the pin, however the checker then behaves. Byte-identity WITHIN a selection is not byte-identity over the file — the pin''s reach ends where its selector does. That is stated here as a structural fact about the selector and NOT as a measured checker outcome, because an outcome claim about a mutation is a declared, harness-executed `CLAIMS` entry under `testing.mutation-claims-are-executed` or it is not written, wherever it is written — this record included. The residual is #891 code and predates this record; stating it is this record''s own rule applied to one of its own worked pins. Each of the three image-build pins faults with a request for the reason in the same commit, and two of them name the CONSTANT to update (`PINNED_STAGE_COMMANDS`, `PINNED_VITE_CONFIG`) while the script-map fault names the FILE and prints both maps. The hook-preamble fault asks for neither: it reports the divergent lines, since the two canonical strings ARE what a deliberate change edits.'
|
||||
---
|
||||
**The class, and what approximating it costs.** A predicate over an artifact with a grammar is a partial
|
||||
reimplementation of that grammar, defeated not by a clever attacker but by an ordinary respelling — and each defeat
|
||||
looks local, one more case to handle, so the sequence stops only when someone stops it. Measured 2026-08-30 across
|
||||
two unrelated subjects: a guard parsing shell text to decide whether a command runs the SPA suite was wrong **nine**
|
||||
ways from one mechanism (#887), its replacement — a partial match of `web/vite.config.ts` — **seven** further ways,
|
||||
and over a hook preamble **three** successive lexical rules each fell to the next shape (#891). Every fix was locally
|
||||
correct; none of the three sequences converged. All three ended in the same move, and what the move buys is a fail
|
||||
DIRECTION: a pin's failure direction is a false RED, which a human reads in the diff; a shape-matcher's is a false
|
||||
GREEN, which nobody reads. Which edits a pin accepts is its NORMALISATION's business, so every pin — whole-file or
|
||||
selected — declares that normalisation and what it cannot see, which is what `mechanics:` does for both worked pins.
|
||||
|
||||
**Why the pin is the DEFAULT and not the remedy — the first thing #901 left open.** The rejected alternative is the
|
||||
tempting one: write the shape-matcher, and pin only once it has been defeated. It is rejected because of what the
|
||||
trigger event is made of. A shape-matcher's failure is a false GREEN, which the guard by construction does not
|
||||
report; the defeat therefore arrives from a reviewer or from an incident, so its absence measures who has looked
|
||||
rather than what the input can be. Both measurements above are that asymmetry: neither predicate withdrew because a
|
||||
count got high, and both had the precedent available for every round. The second reason is that switching later
|
||||
costs more than starting there: a withdrawal costs the rounds already spent AND the proofs calibrated against the
|
||||
narrow clause (rider 2), so "pin it when it breaks" understates its own bill.
|
||||
|
||||
**What a shape-matcher's exception argument must contain — the second thing #901 left open.** The four requirements
|
||||
are chosen so a reviewer can REFUSE one: each is a thing the author either wrote or did not. Two alternatives are
|
||||
rejected. A NUMERIC bar ("survives N adversarial spellings") measures the reviewer's imagination, not the input
|
||||
space, and rises while the defect stands — it would have licensed #887's parser through most of those nine defects.
|
||||
A REVIEWER SIGN-OFF bar is worse: it makes the exception depend on the very signal the threshold says arrives late.
|
||||
What survives is requirement 2, the only candidate that is a claim about the ARTIFACT rather than about the effort
|
||||
spent on it, and requirement 3, which forces the fail-direction claim to be executed, not reasoned.
|
||||
|
||||
**Rider 1 — a pin assumes it pins the artifact that still DECIDES.** This is where the pinned form fails, silently,
|
||||
so it is the question to ask of every new pin. In #887 every hole found after the first withdrawal was authority
|
||||
moving where the pin was not looking: another FILE that OUTRANKS the pinned one (the vite config ordering, a
|
||||
VERSION-bound reading, is dated in `docs/guard-inventory.md`'s `test_image_build_delegates_the_spa_suite.py` row
|
||||
rather than copied here), another OCCURRENCE in the same file, another WORKFLOW (a `needs:` edge naming a different
|
||||
job called `test`), a HOOK the pinned command invokes (a vite plugin's `buildStart`, an npm `prebuild`/`preinstall`
|
||||
script). A pin turns the SPELLING problem into its normalisation's and reduces the MEANING problem to a list that can
|
||||
be worked through; a pin sold as immune to both stops being re-examined.
|
||||
|
||||
**Rider 2 — widening a clause can retire its own evidence.** A canary asserting that a finer mutation still SURVIVES
|
||||
is calibrated against a deliberately coarse clause. Widen that clause and the canary does not go red, it goes
|
||||
vacuous — indistinguishable from a passing proof at the point of reading — so the widening commit re-derives every
|
||||
proof calibrated against the narrow form. Same shape as the `DETECTOR` grade in
|
||||
`testing.mutation-claims-are-executed`: a surviving mutation is worth something only while the precondition that
|
||||
made it survive still holds.
|
||||
|
||||
**Where this sits against its neighbours, so it is not read as a duplicate.** The two incidents keep their own
|
||||
records — `ci.image-build-delegates-the-spa-suite` and `process.hook-resolves-inputs-from-repo-root` — and this is
|
||||
the class they instantiate, while `testing.guard-derives-population-from-source` answers the case where the
|
||||
authoritative source is MISSING (create one, never approximate it with a predicate over text); this one, the case
|
||||
where the artifact IS present and authoritative and its GRAMMAR is what gets approximated.
|
||||
`docs/defect-shapes-773.md` does NOT carry this rule: its §3.6 partition lists the nearest class, string-predicate
|
||||
churn, as `no detector proposed`, and §3.7 argues that class away as a cross-cutting property. Only §4's
|
||||
meta-finding supports the FORM — a class-level rule beats one record per instance — so the rule is new here,
|
||||
resolvable BY TOPIC where `docs/guard-inventory.md` carries its precedent per incident, findable only inside one.
|
||||
|
||||
**What this does not cover.** Predicates over structured input a real parser produces — an AST, the git index,
|
||||
parsed workflow YAML consumed as data — are not approximations and keep their shape matching. And a pin is not
|
||||
mandatory for an artifact edited continuously by many hands: that is requirement 4's own escape hatch, and a pin
|
||||
reddening as background noise is a guard nobody reads.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,77 @@
|
||||
---
|
||||
key: testing.scripted-engine-in-process-net
|
||||
title: '2026-09-05 — Scripted playout is covered in-process, engine plus controller adapter; the Cli.Wrap process and the HTTP transport are permanently out of the automated suite (#563)'
|
||||
status: active
|
||||
since: '2026-09-05'
|
||||
supersedes: testing.scripted-playout-golden-deferred@2026-07-22
|
||||
superseded-by: none
|
||||
rule: 'Scripted playout is characterized at two in-process levels and no further. (1) `SchedulingEngineTests` drives the engine build API directly with substituted repositories — the same setup order `ScriptedPlayoutBuilder` uses (`WithPlayoutId`/`WithMode`/`WithSeed`/`BuildBetween`/`WithReferenceData`/`RestoreOrReset`, and `WithReferenceData` MUST precede the last two or `PlayoutReferenceData.PlayoutHistory` NREs). (2) `ScriptedScheduleControllerTests` replays a committed script fixture (`ErsatzTV.Tests/Controllers/Fixtures/scripted-build.json`) through the REAL `ScriptedScheduleController` + `ScriptedPlayoutBuilderService.MockSession` + `SchedulingEngine` and pins the resulting `PlayoutItem`s as a snapshot in the golden line format — so exactly ONE action-to-engine mapping exists in the repo, the production one. Fixture bodies in that replay are deserialized with `ApiJsonSettings`, the configuration `Startup` applies to `AddNewtonsoftJson`, invoked from the same function rather than mirrored — and two tests WITNESS that choice instead of asserting it, each binding a body that separates the production binder from one plausible replacement: `Production_Body_Binder_Ignores_Required_Members` omits a C# `required` member, which Newtonsoft defaults and System.Text.Json rejects; `Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null` sends an explicit `"order": null`, which `NullValueHandling.Ignore` drops and a bare `JsonSerializerSettings` writes through into a 400. What is shared is the CONFIGURATION, never MVC''s settings OBJECT: `ApiJsonSettings.Create()` applies it to a bare settings object, so MVC''s `MaxDepth` of 32 and its `ProblemDetails`/`ValidationProblemDetails` converters are absent (measured 2026-09-05, pinned by `ApiJsonSettingsTests`); neither is reachable from a scripted request body, and no test may generalize from `Create()` to production past that. What no test observes is the `Startup` registration itself, so the extraction REMOVES the duplicate rather than detecting its drift. The `Cli.Wrap` launch of the user-authored program (exit code, timeout, stdout capture), the Kestrel/middleware/`ApiAuthorizationFilter` transport it calls back over, and MVC model binding as a WRAPPER (the input formatter, model validation and the `[ApiController]` automatic 400 either produces before an action runs — the serializer inside that wrapper is covered, the wrapper around it is not) are NOT tested and are not scheduled to be; that residue is named below rather than argued away. Both fixtures must aim every trimming instruction at an instant BETWEEN two content boundaries: a pad or duration that happens to land on one never enters the engine''s trim branch, so that action''s `trim` argument reaches nothing the snapshot can show. The controller is a thin adapter, NOT a 1:1 pass-through: an unparseable playback order is a 400, an unparseable filler kind SILENTLY degrades to `FillerKind.None`, an unknown build id is a 404, and the engine no-progress `InvalidOperationException` is translated to a 400 — each pinned by a test, because a prose claim of equivalence decays.'
|
||||
signals: 'why is there no scripted golden; scripted integration harness; scripted transport vs engine; SchedulingEngine is in-process testable; Cli.Wrap external process is transport only; shell-out scoped out; MockSession seam; in-process stand-in for a scripted script; engine build API characterization; committed script fixture replay; ScriptedScheduleController is an adapter not a pass-through; scripted filler kind silently falls back to None; scripted playout no-progress halt maps to 400; scripted build 401 blind spot; EnumeratorForContent · paths: `ErsatzTV.Core.Tests/Scheduling/Engine/SchedulingEngineTests.cs`, `ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests.cs`, `ErsatzTV.Tests/Controllers/Fixtures/scripted-build.json`, `ErsatzTV.Tests/Serialization/ApiJsonSettingsTests.cs`, `ErsatzTV/Serialization/ApiJsonSettings.cs`, `ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs`, `ErsatzTV/Controllers/Api/ScriptedScheduleController.cs`, `ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs`, `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs` · issues: #563, #381, #395, #163'
|
||||
mechanics: docs/testing.md → "Scripted playout coverage" and "Golden-file nets"
|
||||
---
|
||||
|
||||
**What carries over from the predecessor.** Sequential (YAML) is golden-able and TZ-independent, and
|
||||
`PlayoutBuildGoldenTests` covers Classic / Block / Sequential. Scripted's *end-to-end pipeline* stays out
|
||||
of that net: `ScriptedPlayoutBuilder` builds nothing itself, it `Cli.Wrap`-executes a user-authored
|
||||
program, hands it `http://localhost:{Settings.UiPort}` plus a build id, and reads the result straight off
|
||||
the in-process engine afterwards. #395's `ContentEnumeratorBuilderTests` remains the direct net over the
|
||||
enumerator-construction switch Scripted and Sequential share.
|
||||
|
||||
**What changed.** The predecessor deferred the whole harness to #563 and described the controller as a
|
||||
"1:1 pass-through". Both are now settled: the coverage exists in-process, and the pass-through wording was
|
||||
wrong — the four adapter mappings above are real behaviour that only a test can hold.
|
||||
|
||||
**Why not the full Kestrel + `Cli.Wrap` harness.** Four measured obstacles, not a preference:
|
||||
|
||||
1. *The production transport is not "a controller behind Kestrel".* `Startup` 404s any request whose Host
|
||||
is not `localhost` and whose `Connection.LocalPort != Settings.UiPort` (a mutable process-wide static),
|
||||
and `ApiAuthorizationFilter` fail-closes every mutating verb. A hand-rolled minimal host would test a
|
||||
transport the product does not have; the real `Startup` drags in the whole DI graph (TvContext, ffmpeg
|
||||
workers, search index, background services), which is what #381 deferred in the first place.
|
||||
2. *The external program is user-authored by definition.* `Playout.ScheduleFile` is an arbitrary command
|
||||
line, so any script we commit is a stand-in we wrote either way. The fork's own driver,
|
||||
`scripts/scripted-schedules/entrypoint.py`, imports `etv_client`, a generated package that exists only
|
||||
in the deploy environment, so it cannot run in the .NET test job without adding a client-generation step.
|
||||
3. *It would put an interpreter and process spawning inside the pure in-memory .NET suite*, with the 30s
|
||||
`Cli.Wrap` timeout nested inside CI's `--blame-hang-timeout 2m` — a new flake class on a shared runner.
|
||||
4. *The stand-in loses nothing the harness would have added at the engine level.* Replaying the fixture
|
||||
through the real controller and the real service keeps a single action-to-engine mapping under test;
|
||||
what is absent is the two transport hops plus MVC binding as a wrapper, and the wrapper is the shallow
|
||||
half — the serializer inside it is configured by `ApiJsonSettings`, the production configuration (not
|
||||
MVC's settings *object*, whose remaining defaults the rule enumerates).
|
||||
|
||||
**The residue, named.** Uncovered: process launch, exit code and timeout handling; Kestrel plus the
|
||||
forwarded-headers / compression / SPA-hosting middleware; MVC model binding as a *wrapper* — the input
|
||||
formatter (content-type selection, malformed-body handling), model validation (a non-nullable reference
|
||||
type picks up an implicit required check there) and the `[ApiController]` automatic 400 either produces
|
||||
before an action runs, since every test hands an action an already-bound object; and
|
||||
`ApiAuthorizationFilter`. The *serializer* inside that wrapper is deliberately not on this list — it is
|
||||
`ApiJsonSettings` itself, and two tests hold the two ways it can be replaced. `ApiJsonSettings` exists so
|
||||
that binder is *defined* once; it is not a drift **detector**, and the branch may not claim one, because
|
||||
nothing observes `Startup.ConfigureServices` and a byte-equal hand-copy of `Apply` is indistinguishable
|
||||
by construction. What the suites do separate is a mirror that has *lost* something. The mechanics doc
|
||||
tabulates which suite separates which half of the configuration, and what neither reaches.
|
||||
|
||||
**Where the calibrating measurements are, and why not here.** `testing.mutation-claims-are-executed`
|
||||
admits a sentence asserting that a specific mutation reddens — or does not redden — a named test only as
|
||||
a `CLAIMS` entry in `scripts/tests/mutation_manifest.py`, executed every run; otherwise it is not
|
||||
written. That harness resolves a proof to `scripts/tests/<node id>` and runs pytest, so an NUnit proof
|
||||
cannot be declared in it and this coverage is entirely NUnit. The binder and `trim` mutants run while
|
||||
building these tests therefore stay in ersatztv#563 — its pull request and the commit messages that
|
||||
ran them — dated. Durable prose carries
|
||||
the mechanism each test is built on instead — which serializer difference, which engine branch — because
|
||||
that is re-checkable by reading the code rather than by trusting a remembered outcome.
|
||||
|
||||
`ApiAuthorizationFilter` on that list is not hypothetical — `ScriptedScheduleController` carries no
|
||||
`[SkipApiAuthorization]` while
|
||||
`ScriptedPlayoutBuilder` passes the script only host, build id and mode, with no `X-Api-Key`, and
|
||||
`entrypoint.py` builds its client with no credential. Whether a scripted build therefore 401s on its first
|
||||
POST in this fork is unverified against a live instance and is tracked in ersatztv#913; it is recorded here
|
||||
as the concrete shape of what an engine-level net cannot see, so the scope-out is not read as a claim of
|
||||
equivalence.
|
||||
|
||||
**Determinism constraints on the fixtures.** Both use Chronological order and only instant-preserving
|
||||
instructions (`AddCount`, `AddAll`, `AddDuration`, `PadUntilExact`). `WaitUntil(TimeOnly)` and
|
||||
`PadUntil(string)` read the LOCAL day and time-of-day, and `PadToNext` becomes offset-sensitive once a
|
||||
call has localized `CurrentTime`, so introducing any of them — or a `shuffle` order — reopens the
|
||||
timezone and seed question the fixtures were built to close.
|
||||
@@ -96,7 +96,7 @@ another doc or an old issue comment should land here and then follow the link.
|
||||
- 2026-07-21 — An infra-shaped red under host load is not a code failure (#542) — [`ci.infra-shaped-red-under-load`](records/ci/infra-shaped-red-under-load.md)
|
||||
- 2026-07-21 — Arm the CI monitor at PR-open, via the commit-status endpoint (#542) — [`ci.monitor-armed-at-pr-open`](records/ci/monitor-armed-at-pr-open.md)
|
||||
- 2026-07-21 — BOM/format pre-push detection: use the `xxd` loop, and run `dotnet format --include` under `bash -c` (#542) — [`process.bom-format-detection-recipe`](records/process/bom-format-detection-recipe.md)
|
||||
- 2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4; REST route re-probed on 1.27.1 (#542) — [`ci.batch-pushes-no-cancel-route`](records/ci/batch-pushes-no-cancel-route.md)
|
||||
- 2026-07-21 — Batch your pushes: no agent-side cancel route; REST absence confirmed in the v1.27.1 router source, web UI still 1.25.4-dated (#542, #869) — [`ci.batch-pushes-no-cancel-route`](records/ci/batch-pushes-no-cancel-route.md)
|
||||
- 2026-07-21 — Before deleting a "workaround", enumerate every behavior it provided (#542) — [`process.enumerate-workaround-behaviors-before-deleting`](records/process/enumerate-workaround-behaviors-before-deleting.md)
|
||||
- 2026-07-21 — Bound parallel builds by FREE RAM, never regenerate goldens (#542) — [`process.build-concurrency-limits`](records/process/build-concurrency-limits.md)
|
||||
- 2026-07-21 — Build and verify locally, then trust it; CI confirms (#542) — [`ci.verify-locally-ci-confirms`](records/ci/verify-locally-ci-confirms.md)
|
||||
|
||||
@@ -68,7 +68,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
|
||||
| **Seasonal / date-conditional scheduling** (#73) | Holiday/seasonal channels are **not a separate feature** — they are the existing date predicate on `IAlternateScheduleItem`, implemented by `ProgramScheduleAlternate` (Classic) and `PlayoutTemplate` (Block), evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match by `Index`, catch-all last). **Leaving `StartYear`/`EndYear` empty makes the range repeat every year** — the "set once, works every December" switch; explicit years (required in pairs) mean a one-off window and disable wrap-around detection. Wrap-around (Nov→Feb) and invalid/leap dates (Feb 31) are handled. No *soft* prioritization primitive exists (binary first-match-wins); that ask belongs to #70's weighting work. See `channels.md` → "Recipe: seasonal / holiday programming" and `decisions.md` 2026-07-17. | `IAlternateScheduleItem`, `ProgramScheduleAlternate`, `PlayoutTemplate` | `/app/playouts/{id}/alternate-schedules`, `/app/playouts/{id}/templates` |
|
||||
| **Playback order** | How a schedule item's source(s) are sequenced (`PlaybackOrder`). Note three that are easily confused: **`Shuffle`** is Fisher–Yates over the flattened items, so airtime is implicitly proportional to collection size (a 200-episode show swamps a 20-episode one). **`ShuffleInOrder`** is a balanced shuffle (keyj) that pads sources to equal length with non-emitting spacers — it plays every item exactly once per cycle, so it prevents *clumping* but leaves airtime proportional to size; it is **not** fair-share. **`WeightedShuffle`** (#70) picks a *source* by smooth weighted round-robin then takes its next item, so each source's `Weight` is its share of airtime — equal weights (the default) mean equal airtime regardless of library size, with small sources looping. Classic engine only; rejected at the write path for playlist/block items. See `decisions.md` 2026-07-17. | `PlaybackOrder`, `MultiCollectionItem.Weight`, `MultiCollectionSmartItem.Weight` | `WeightedShuffle` is offered as a Playback Order **only** on classic schedule items whose source is a MultiCollection (`web/src/schedules/itemRules.ts`, #404); the per-source weights themselves are edited at `/app/multi-collections` |
|
||||
| **Watermark** | `ChannelWatermark` image overlay; attached at channel, schedule-item, block-item, deco, or playout-item level with position/size/opacity. | `ChannelWatermark`, `DecoWatermark`, `BlockItemWatermark`, `ProgramScheduleItemWatermark` | `/app/watermarks` |
|
||||
| **Graphics element** | YAML-authored (`Text`/`Image`/`Subtitle`/`Motion`/`Script`) render-engine overlay, distinct from the image-only `ChannelWatermark` system. Attaches via 5 parallel join tables: `PlayoutItemGraphicsElement`, `ProgramScheduleItemGraphicsElement`, `BlockItemGraphicsElement`, `DecoGraphicsElement`, and (#74) **`ChannelGraphicsElement`** — a direct `Channel`-level attachment that did not exist before #74. `GraphicsElementSelector.SelectGraphicsElements` treats channel-level elements as the final fall-through **base layer**: they merge with `Merge`-mode deco elements and per-playout-item elements, but a deco in `Override`/`Disable` mode returns before that fall-through and so **suppresses** the channel overlay (and on a **filler** item, a deco whose graphics-elements section is not set to run during filler — `UseGraphicsElementsDuringFiller` false — clears it too, for `Merge` and `Override` alike); `HttpLiveStreamingDirect` always returns empty (ErsatzTV isn't transcoding, so nothing can be burned in). A built-in seeded text element, `on-now-next.yml` (`GraphicsElementDefaults.OnNowNextFileName`), is written once (`GraphicsElementSeeder.SeedOnNowNext`, guarded by the `graphics.on_now_next_seeded` ConfigElement marker, adopt-not-clobber like the #67 watermark seed) and identified to API clients via a server-derived `GraphicsElementResponseModel.builtIn` flag (path-name comparison, not name matching). Edited per-channel at Channel editor → Branding → "Show On Now / Next overlay". See `decisions.md` → `graphics.channel-level-attachment` (#74). | `GraphicsElement`, `ChannelGraphicsElement` | `/app/edit-channel/{id}` (Branding tab); YAML files under `GraphicsElementsTextTemplatesFolder` etc. are not directly SPA-edited |
|
||||
| **Graphics element** | YAML-authored (`Text`/`Image`/`Subtitle`/`Motion`/`Script`) render-engine overlay, distinct from the image-only `ChannelWatermark` system. Attaches via 5 parallel join tables: `PlayoutItemGraphicsElement`, `ProgramScheduleItemGraphicsElement`, `BlockItemGraphicsElement`, `DecoGraphicsElement`, and (#74) **`ChannelGraphicsElement`** — a direct `Channel`-level attachment that did not exist before #74. `GraphicsElementSelector.SelectGraphicsElements` treats channel-level elements as the final fall-through **base layer**: they merge with `Merge`-mode deco elements and per-playout-item elements, but a deco in `Override`/`Disable` mode returns before that fall-through and so **suppresses** the channel overlay (and on a **filler** item, a deco whose graphics-elements section is not set to run during filler — `UseGraphicsElementsDuringFiller` false — clears it too, for `Merge` and `Override` alike); `HttpLiveStreamingDirect` always returns empty (ErsatzTV isn't transcoding, so nothing can be burned in). A built-in seeded text element, `on-now-next.yml` (`GraphicsElementDefaults.OnNowNextFileName`), is written once (`GraphicsElementSeeder.SeedOnNowNext`, guarded by the `graphics.on_now_next_seeded` ConfigElement marker, adopt-not-clobber like the #67 watermark seed) and identified to API clients via a server-derived `GraphicsElementResponseModel.builtIn` flag (`GraphicsElementDefaults.IsOnNowNext` — ordinal full-seeded-path equality plus `Kind == Text`, not name matching and not a bare filename, #568). Edited per-channel at Channel editor → Branding → "Show On Now / Next overlay". See `decisions.md` → `graphics.channel-level-attachment` (#74). | `GraphicsElement`, `ChannelGraphicsElement` | `/app/edit-channel/{id}` (Branding tab); YAML files under `GraphicsElementsTextTemplatesFolder` etc. are not directly SPA-edited |
|
||||
| **Collection** | Manual list of media items (`CollectionItem`). | `Collection` | `/app/collections` |
|
||||
| **SmartCollection** | Saved search — a `Query` string, no static item list. | `SmartCollection` | `/app/collections` |
|
||||
| **MultiCollection** | Combines multiple `Collection`s and/or `SmartCollection`s (with grouping via `MultiCollectionItem`/`MultiCollectionSmartItem`). Both join entities carry a per-source `Weight` (default 1) used by `PlaybackOrder.WeightedShuffle` (#70) and ignored by every other order — the two are mirrors, so a change to one belongs on the other. The editor exposes a per-source weight input (1..1000, mirroring the API validator) with a computed % share and a "Reset to fair share" action; it round-trips `weight` from the GET because the PUT replaces the item list (#404). | `MultiCollection` | `/app/multi-collections` (#151, weight UI #404) |
|
||||
|
||||
@@ -121,8 +121,48 @@ never overwrites an operator's file. Two rules govern it after that:
|
||||
- Updating the shipped default → `graphics.seeded-template-upgrade-by-fingerprint`.
|
||||
- It is attached to channels by default → `graphics.on-now-next-on-by-default`.
|
||||
|
||||
Identity is the **filename** (`GraphicsElementDefaults.OnNowNextFileName`), never the editable `Name`;
|
||||
`GraphicsElementResponseModel.BuiltIn` is derived from it server-side so the SPA never name-matches.
|
||||
Identity is the **full seeded path plus `Kind == Text`**, never the editable `Name` and never the
|
||||
bare filename (`OnNowNextFileName`) alone — a filename-only, folder-agnostic comparison let a user
|
||||
element named exactly `on-now-next.yml` in a different template folder also report `builtIn:true`
|
||||
(#568). Both halves live in one predicate, `GraphicsElementDefaults.IsOnNowNext(path, kind)`, and
|
||||
both `GraphicsElementResponseModel.BuiltIn` and `GraphicsElementSeeder.GetBuiltInElementId` resolve
|
||||
through it whole, so the SPA never name-matches and the two sites cannot answer differently about
|
||||
the same row. `Kind` was a `Where` clause on the seeder's lookup alone until #568, and that split is
|
||||
exactly what let the API report an Image row at the seeded path as `builtIn:true` while the lookup
|
||||
refused to treat it as the built-in element.
|
||||
|
||||
`Kind` is load-bearing rather than decorative, which is why it is inside the identity rather than
|
||||
a filter any one caller may add: the seeder's own "does this row exist yet?" test —
|
||||
`EnsureBuiltInElementRow`, which decides whether to create the row at startup — calls
|
||||
`GetBuiltInElementId`, so a wrong-kind row at the seeded path answering yes would suppress the Text
|
||||
row every consumer resolves. Asking that question a second way, as its own SQL `Path == target`
|
||||
comparison, could answer differently in two ways, each leaving the built-in element undiscoverable
|
||||
for the life of the install (#568): the collation one below, and `Kind`, which the SQL check ignored
|
||||
(rows 18 and 34). Creating the row stays idempotent because the path it writes is the path the
|
||||
lookup matches — held by `Repeated_Seeding_Does_Not_Accumulate_Element_Rows` (row 21), which reddens
|
||||
if the two drift.
|
||||
|
||||
Each half of the identity rejects a row on its own, so a row that is BOTH of another kind AND
|
||||
outside the seeded folder is still refused when either half is dropped — the survivor rejects it. A
|
||||
test asserting such a row is ignored therefore cannot appear in row 18's red set; it is a test that
|
||||
cannot fail, and the combination is deliberately not shipped. Row 10 drops the identity call
|
||||
entirely, which is a different mutation from dropping either half.
|
||||
|
||||
The same arithmetic constrains how each negative case is SEEDED, at both discriminator sites: a
|
||||
row varies exactly one half of the identity and holds the other at the seeded value. The
|
||||
outside-folder rows carry `Kind == Text`, the wrong-kind rows sit at the seeded path. A negative row
|
||||
that varies both is refused by the surviving half of the *pre-#568* predicate too — filename plus
|
||||
`Kind == Text` — so a composite revert to it would pass every discriminator test at that site while
|
||||
restoring the folder-agnostic bug the issue reports (#568).
|
||||
|
||||
`IsOnNowNext` is **ordinal**, and every caller applies it **in memory** rather than in a `Where`
|
||||
clause. That is not incidental: `GraphicsElement.Path` takes no explicit collation (`TvContext`
|
||||
pins one only on the listed name/title columns), so a SQL `Path ==` comparison is case-sensitive
|
||||
under SQLite and normally case-INsensitive under MySQL. Evaluating one discriminator site in SQL and
|
||||
another in memory is what would let them disagree, on MySQL only. The SQLite test suite cannot
|
||||
distinguish the two — BINARY collation and an ordinal comparison agree on every input —
|
||||
so this is held by keeping the comparison out of SQL, not by a test; what the case-variant tests in
|
||||
the table below pin is the ordinal rule itself, against a loosening to `OrdinalIgnoreCase`.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -146,8 +186,10 @@ Three traps this table is built to avoid:
|
||||
|
||||
- **Measure against the whole project, never a per-fixture filter.** A filtered run structurally
|
||||
cannot observe a red in another fixture, so it under-reports the failure set while looking precise.
|
||||
Thirteen rows below redden more than one test, and two of them span two fixture classes — those two
|
||||
are exactly what a per-fixture filter cannot see.
|
||||
Twenty-four rows below redden more than one test, and five of them (13, 18, 22, 33, 41) span two
|
||||
fixture classes — those five are exactly what a per-fixture filter cannot see. Both figures are
|
||||
counted from the table itself, so a row added without recounting them makes this bullet quietly
|
||||
false — recount both whenever a row is added or its red set changes.
|
||||
- **A mutation that fails to COMPILE is not a result.** Warnings-as-error turn the obvious mutation
|
||||
shapes into build failures — `CS0162` for `if (true) { return; }`, `CS1717` for self-assignment,
|
||||
Sonar `S3981` for a constant-folded condition — and a build failure emits no test summary at all,
|
||||
@@ -182,6 +224,14 @@ a test that cannot fail:
|
||||
Rows are written per clause, not per block: a row naming a whole `if` or a whole style-merge block
|
||||
hides which individual fields inside it are actually reachable from a test.
|
||||
|
||||
A row number is an identity, not a position: prose elsewhere cites rows by number (the built-in
|
||||
element section above cites rows 18 and 21), so a new row takes the next unused number and the rest
|
||||
are never renumbered. A clause that loses its red keeps its number vacant rather than freeing it for
|
||||
some other clause — reusing it would silently re-point every existing citation. Reinstating the SAME
|
||||
clause under its OWN number is the sole exception, because that is the same identity and not a new
|
||||
one: row 18 is the `Kind == Text` filter, and stays that clause whether or not it currently has a
|
||||
red.
|
||||
|
||||
| # | Clause mutated | Reddens (measured) |
|
||||
|---|---|---|
|
||||
| 1 | DrawBackgroundBox returns immediately | `An_Oversized_Border_Is_Clamped_And_Does_Not_Flood_The_Element`<br>`Background_Color_Fills_The_Box`<br>`Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range`<br>`Background_Opacity_Percent_Scales_The_Alpha`<br>`Background_Padding_Actually_Insets_The_Text`<br>`Border_Color_Draws_A_Border_Distinct_From_The_Fill`<br>`Border_Color_Without_An_Explicit_Width_Draws_A_Hairline`<br>`Corner_Radius_Rounds_The_Corner_Away` |
|
||||
@@ -193,7 +243,7 @@ hides which individual fields inside it are actually reachable from a test.
|
||||
| 7 | line-ending normalisation dropped from Normalize | `Upgrades_An_Untouched_Previous_Default_With_Windows_Line_Endings` |
|
||||
| 8 | HLS-Direct exclusion removed from the backfill | `Skips_Hls_Direct_Channels_Where_The_Overlay_Cannot_Render` |
|
||||
| 9 | already-attached filter removed | `Does_Not_Duplicate_An_Existing_Attachment`<br>`While_Armed_A_Restored_Element_Is_Attached_To_Every_Eligible_Channel` |
|
||||
| 10 | filename check removed from the built-in lookup | `Ignores_A_Non_Built_In_Element_With_A_Different_Filename` |
|
||||
| 10 | the whole `IsOnNowNext(c.Path, c.Kind)` filter removed from the built-in lookup (#568) — both halves of the identity at once, so every candidate row matches | `A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row`<br>`Ignores_A_Case_Variant_Of_The_Seeded_Path`<br>`Ignores_A_Non_Built_In_Element_With_A_Different_Filename`<br>`Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder` |
|
||||
| 11 | graphics.on_now_next_default_attached guard never fires | `Does_Not_Re_Attach_After_An_Operator_Clears_It` |
|
||||
| 12 | create-time `ChannelGraphicsDefaults.Attach` call removed from `CreateChannelHandler` | `Attaches_The_Built_In_Element_To_A_New_Channel` |
|
||||
| 13 | HLS-Direct skip removed from the create path | `Leaves_An_Hls_Direct_Channel_Alone_Because_Nothing_Can_Render_There`<br>`Should_Not_Attach_The_Overlay_To_An_Hls_Direct_Channel` |
|
||||
@@ -201,11 +251,11 @@ hides which individual fields inside it are actually reachable from a test.
|
||||
| 15 | FitTextBlock drops HaloColor again | `The_Scale_Path_Preserves_Halo_Blur`<br>`The_Scale_Path_Preserves_The_Halo` |
|
||||
| 16 | unrounded inset subtracted from the budget | `Fractional_Padding_Still_Respects_Width_Percent` |
|
||||
| 17 | inset clamp removed | `An_Oversized_Padding_Is_Clamped_To_The_Largest_That_Fits` |
|
||||
| 18 | Kind == Text filter dropped from the lookup | `Ignores_A_Same_Named_Element_Of_A_Different_Kind` |
|
||||
| 18 | `kind == GraphicsElementKind.Text` conjunct dropped from `GraphicsElementDefaults.IsOnNowNext` (#568) — one row for both discriminator sites since they share the predicate; the lookup is what `EnsureBuiltInElementRow` asks, so a wrong-kind row at the seeded path would answer for the Text row that never then gets created, and the API would report it as `builtIn:true` | `A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row`<br>`GetAllGraphicsElementsForApi_Should_Not_Mark_A_Wrong_Kind_Row_At_The_Seeded_Path_As_BuiltIn` |
|
||||
| 19 | Sanitize passes values through unchecked | `Non_Finite_And_Absurd_Box_Values_Do_Not_Corrupt_The_Geometry` |
|
||||
| 20 | the BOX itself is no longer clamped (only insetPixels) | `An_Oversized_Border_Is_Clamped_And_Does_Not_Flood_The_Element` |
|
||||
| 21 | duplicate guard removed from EnsureBuiltInElementRow | `Repeated_Seeding_Does_Not_Accumulate_Element_Rows` |
|
||||
| 22 | EnsureBuiltInElementRow removed from the ALREADY-SEEDED branch | `An_Already_Seeded_Install_Missing_Its_Element_Row_Gets_One`<br>`Repeated_Seeding_Does_Not_Accumulate_Element_Rows` |
|
||||
| 22 | EnsureBuiltInElementRow removed from the ALREADY-SEEDED branch | `A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row`<br>`An_Already_Seeded_Install_Missing_Its_Element_Row_Gets_One`<br>`Repeated_Seeding_Does_Not_Accumulate_Element_Rows` |
|
||||
| 23 | non-atomic in-place write restored | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact`<br>`A_Failed_Write_Leaves_The_Original_Template_Intact` |
|
||||
| 24 | marker written even with nothing resolved | `Stays_Armed_When_There_Is_No_Built_In_Element_To_Attach`<br>`While_Armed_A_Restored_Element_Is_Attached_To_Every_Eligible_Channel` |
|
||||
| 25 | the atomic replace becomes a non-atomic copy | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact` |
|
||||
@@ -216,3 +266,18 @@ hides which individual fields inside it are actually reachable from a test.
|
||||
| 30 | per-style halo_blur override dropped from the style merge | `A_Non_Base_Style_Uses_Its_Own_Halo_Blur` |
|
||||
| 31 | the 0-100 opacity clamp is removed | `Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range` |
|
||||
| 32 | the lineup create path stops attaching the built-in element | `Should_Attach_The_Built_In_On_Now_Next_Element` |
|
||||
| 33 | `IsOnNowNext` loosened from `Ordinal` to `OrdinalIgnoreCase` (#568) — one row for both discriminator sites, since they share the predicate | `GetAllGraphicsElementsForApi_Should_Not_Mark_A_Case_Variant_Of_The_Seeded_Path_As_BuiltIn`<br>`Ignores_A_Case_Variant_Of_The_Seeded_Path` |
|
||||
| 34 | `EnsureBuiltInElementRow`'s existence check re-derived as a SQL `AnyAsync(e => e.Path == target)` instead of asking `GetBuiltInElementId` (#568) | `A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row` |
|
||||
| 35 | `GraphicsElementIdsMustExist` removed from `UpdateChannelHandler.Validate` (#568) — the unknown-id 422 the issue asks for, and with it the count cap and the diagnostic cap that validator carries, and the lost-race recheck, which re-runs `Validate` whole | `Should_Cap_The_Ids_Echoed_Back_In_The_Unknown_Id_422`<br>`Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count`<br>`Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_Unknown_GraphicsElementId_With_422_Not_500`<br>`Should_Reject_When_One_Of_Several_GraphicsElementIds_Is_Unknown`<br>`Should_Translate_An_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
|
||||
| 36 | `GraphicsElementIdsMustExist` removed from `UpdateDecoHandler.Validate` (#568) — and so from the lost-race recheck too, which re-runs `Validate` whole | `Should_Reject_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Unknown_GraphicsElementId_With_A_Validation_Error_Not_A_Throw`<br>`Should_Translate_A_Deco_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
|
||||
| 37 | `WatermarkIdsMustExist` removed from `UpdateDecoHandler.Validate` (#568) — and so from the lost-race recheck too | `Should_Reject_More_Than_The_Maximum_Number_Of_WatermarkIds`<br>`Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Unknown_WatermarkId_With_A_Validation_Error_Not_A_Throw`<br>`Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
|
||||
| 38 | `idsAreConsumed: ConsumesGraphicsElementIds(request)` replaced by `idsAreConsumed: true` at `UpdateDecoHandler.GraphicsElementIdsMustExist`'s call site (#568) — a validator may only reject ids the apply path would consume | `Should_Ignore_An_Unknown_GraphicsElementId_When_The_Mode_Does_Not_Consume_It` |
|
||||
| 39 | the same replacement at `UpdateDecoHandler.WatermarkIdsMustExist`'s call site (#568) | `Should_Ignore_An_Unknown_WatermarkId_When_The_Mode_Does_Not_Consume_It` |
|
||||
| 40 | `GetAllGraphicsElementsForApiHandler`'s `BuiltIn` reverted from `GraphicsElementDefaults.IsOnNowNext(e.Path, e.Kind)` to `Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName` (#568) — the API-side call, the folder-agnostic and kind-agnostic shape the issue reports | `GetAllGraphicsElementsForApi_Should_Not_Mark_A_Wrong_Kind_Row_At_The_Seeded_Path_As_BuiltIn`<br>`GetAllGraphicsElementsForApi_Should_Not_Mark_Same_Filename_Outside_Seeded_Folder_As_BuiltIn` |
|
||||
| 41 | the raw-count cap removed from `Validators.IdsMustExist` (#568) — one clause, three call sites, so its red set is what shows the cap is shared rather than restated | `Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count`<br>`Should_Reject_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_More_Than_The_Maximum_Number_Of_WatermarkIds`<br>`Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them` |
|
||||
| 42 | `Validators.DescribeIds` stops truncating and joins every missing id (#568) | `Should_Cap_The_Ids_Echoed_Back_In_The_Unknown_Id_422` |
|
||||
| 43 | the `DbUpdateException` catch disarmed in `UpdateChannelHandler.ApplyUpdateRequestTranslatingLostRace` (#568) — retyped to an exception the save cannot raise, so the FK failure propagates as it did before the fix | `Should_Translate_A_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422`<br>`Should_Translate_An_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
|
||||
| 44 | the same catch disarmed in `UpdateDecoHandler.ApplyUpdateRequestTranslatingLostRace` (#568) | `Should_Translate_A_Deco_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422`<br>`Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
|
||||
| 45 | the lost-race recheck in `UpdateChannelHandler.ApplyUpdateRequestTranslatingLostRace` narrowed from `Validate` back to `GraphicsElementIdsMustExist` alone (#568) — the recovery enumerating its own fields instead of re-asking the validator set, which omits every other FK the same save writes | `Should_Translate_A_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
|
||||
| 46 | the same narrowing in `UpdateDecoHandler.ApplyUpdateRequestTranslatingLostRace` (#568) | `Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
|
||||
| 47 | the `idsAreConsumed` gate hoisted ABOVE the raw-count cap in `Validators.IdsMustExist` (#568) — the shape the deco mode gate had while it short-circuited the whole call, which took the cap with it | `Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them` |
|
||||
|
||||
+107
-34
File diff suppressed because one or more lines are too long
@@ -364,6 +364,9 @@ HARD CONSTRAINTS:
|
||||
task-specific delta. → `docs.convention-docs-session-start`
|
||||
- Run `scripts/select-queue.sh` for queue selection; trust its deps/tiering/ordering and resolve only its
|
||||
`CLAIM?`/`UMBRELLA?` flags. → `startup.parallel-orientation`
|
||||
- A session that runs several issues at once is an orchestrated session: `docs/handoffs/orchestration.md`
|
||||
owns its mechanics and `orchestrator-prompt.md` starts it; every slot is still bound by this file.
|
||||
→ `process.orchestrated-session`
|
||||
- Claim with `in-progress` before working — but **check for an existing claim first** (open PRs
|
||||
referencing the issue, remote branches naming it, comments predating the label, a fresh
|
||||
`git fetch`), because a label prevents duplicate *pickup*, not duplicate *work*: #649 was
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# Orchestrated sessions
|
||||
|
||||
How one session closes several issues at once: an orchestrator that never implements, subagents
|
||||
that each own one issue in one worktree, and the repo's merge gate between every branch and `main`.
|
||||
This page owns the mechanics (`process.orchestrated-session`). `orchestrator-prompt.md` is the
|
||||
standing prompt that starts such a session; `chicorytv-issue-queue.md` still owns queue selection,
|
||||
claiming and the HARD CONSTRAINTS, and nothing here relaxes them — an orchestrated session is
|
||||
several single-issue sessions run by one referee, and each of them is bound by the whole kickoff.
|
||||
|
||||
## Roles
|
||||
|
||||
| Role | Model, effort | Does |
|
||||
|---|---|---|
|
||||
| Orchestrator | Fable or Opus, high | Reads verdicts and evidence, ticks the `## Done-when` boxes, posts the H10 verdict, merges through the consent hook, clears labels, removes worktrees, watches for collisions and hangs. Never picks, claims, codes or pushes. |
|
||||
| Picker + 2 refuters | sonnet, medium | Apply `scripts/select-queue.sh` and the kickoff's claim and bundle rules to live Gitea state; return up to `count` mutually non-colliding picks. `.claude/workflows/ersatztv-pick-next.js`, args `{taken, closed, notes, count}`. |
|
||||
| Recon (large issues) | Opus, high | Reads the docs the task-signal map names, finds the handlers, call sites, tests and guards, returns a plan. |
|
||||
| Implementer | Opus high; sonnet medium for `size: small` | Claims, builds, rebases onto `origin/main`, runs the local gate, sits inside the review loop, supplies the evidence per Done-when box. |
|
||||
| Reviewers | correctness: Opus high, in its own worktree; conformance: sonnet high; cross-family: Codex via `codex exec` for the rubric's risk classes, with a cold Opus review-only fallback when Codex cannot run | Run the gate themselves; `blocking` / `should-fix` / `nit`. |
|
||||
| Fixer | same tier as the implementer | Answers findings; two rounds, then the orchestrator decides. |
|
||||
| Finisher | sonnet, medium | The single push, the PR, the closing record. Ticks nothing. |
|
||||
|
||||
**Sizing is a rule** (`process.per-agent-model-routing`): every launch names its model and effort.
|
||||
sonnet at medium for picking, refuting, small well-specified fixes and mechanical finishing; sonnet at
|
||||
high for conformance review; Opus at high for recon, implementation, fixing and correctness review;
|
||||
Fable for orchestrating and for the frontier escalations the kickoff lists, never for implementing.
|
||||
Cross-family review is mandatory for locks/concurrency, auth/security, API write-path handlers, DB
|
||||
migrations and diffs over ~150 changed C# lines (`process.independent-review-rubric`); the picker's
|
||||
`risk` field decides. When Codex cannot run, the workflow substitutes a cold same-family review-only
|
||||
agent and the PR body states the substitution and its reason, as that record requires.
|
||||
|
||||
`.claude/workflows/ersatztv-issue-build.js` runs claim → recon → implement → gate → review → fix →
|
||||
land for one issue or bundle (args: `issues, slug, title, size, risk, area, needs_e2e, port,
|
||||
body_summary, done_condition, files_likely, avoid, trailer`; `issues`, `trailer` and `port` are
|
||||
required and the script refuses without them). `ersatztv-resume-branch.js` finishes or fixes a
|
||||
paused branch from a JSON brief. Both are passed to the Workflow tool inline; the tool persists the
|
||||
script and returns a path for later runs.
|
||||
|
||||
## Isolation
|
||||
|
||||
- One worktree per issue: `~/orca/workspaces/ersatztv/wt-<n>`, branch `<n>-<slug>`, cut from a
|
||||
freshly fetched `origin/main` (`process.shared-tree-readonly`), by absolute path. Never under
|
||||
`/tmp` — macOS purges it mid-session — and never the shared checkout.
|
||||
- The commit guard (`pretooluse-worktree-guard.sh`) reads a `.claude-worktree-owner` marker that
|
||||
`posttooluse-worktree-marker.sh` writes on `git worktree add`. Measured 2026-09-04: a workflow
|
||||
agent's `git worktree add` writes no marker (project hooks do not run for a workflow's agents),
|
||||
and a `~`-prefixed path from the session writes none either, so orchestrated worktrees carry no
|
||||
marker and the guard fails open inside them — acceptable, because they all belong to one session.
|
||||
A sibling *session's* worktree keeps its marker and its denial
|
||||
(`process.foreign-worktree-plumbing-merge`), so a denial inside an orchestrated worktree means the
|
||||
worktree is foreign: the scripts stop and report it, and never overwrite the marker.
|
||||
- `web/node_modules` is per worktree: clone it from the shared checkout with `cp -Rc` when the
|
||||
lockfiles match, otherwise `npm ci`. The shared copy is kept current by
|
||||
`scripts/refresh-shared-checkout.sh` at session end.
|
||||
- **One .NET-building slot at a time on this host; docs and Python slots may run beside it.** A
|
||||
slot's correctness reviewer builds .NET in its own worktree, and on a rubric-class change whose
|
||||
Codex runner cannot run the fallback is a second such reviewer, so a review round can be two
|
||||
build-and-test pipelines on top of the implementer's lingering MSBuild node servers. Measured
|
||||
2026-09-05 on the 16 GB Mac: three slots in review reached load 82 and swap exhaustion; one slot's
|
||||
round alone took swap from 6.8 GB to 10.8 GB in three minutes with the two reviewers concurrent.
|
||||
The scripts therefore start the fallback reviewer only after the lenses return (the Codex runner
|
||||
builds nothing and runs beside them). The cap stays at one even so: the implementer's own build,
|
||||
one reviewer pipeline and the MSBuild node servers that linger after each build already fill the
|
||||
budget beside the host's other sessions; re-measure before raising it.
|
||||
`process.build-concurrency-limits` is the standing rule (under 20% free RAM launch nothing that
|
||||
builds, under 10% pause); the Agent hook's RAM gate does not see a workflow's agents, so the
|
||||
orchestrator holds the count itself.
|
||||
- **Live-E2E runs per worktree on the slot's own port.** `scripts/e2e-local.sh` refuses concurrent
|
||||
runs *within one repo root* because each run re-copies that root's `wwwroot`; across worktrees
|
||||
there is nothing shared but the ports, so every slot gets a distinct `port` argument, one run at a
|
||||
time inside the slot, and the launcher's pre-flight refuses a busy port and names the holder. A
|
||||
busy port is reported, never taken over; kill only the PID the launcher printed to you
|
||||
(`testing.e2e-cleanup-scope-by-pid`).
|
||||
- Reviewers create scratch only under `/private/tmp`, delete nothing outside it, and never build a
|
||||
path with `..` segments. A reviewer that must build gets `isolation: worktree` and fetches the
|
||||
unpushed branch into it (`git fetch <worktree> <branch> && git checkout --detach FETCH_HEAD`); it
|
||||
never builds in the slot's worktree.
|
||||
|
||||
## Landing a branch
|
||||
|
||||
The order is the kickoff's (`process.pr-routine-sequence`, `process.local-gate-before-push`), with
|
||||
the review loop *inside* the worktree, before the single push:
|
||||
|
||||
1. **Implementer**: rebase onto a fresh `origin/main`, then the local gate — `dotnet build` and
|
||||
`dotnet test` on the touched test projects (the full solution for anything under
|
||||
`ErsatzTV.Core`), `npm run check:api && npm run lint && npm run typecheck && npm run build &&
|
||||
npm test` under `web/` for SPA changes, `PYTHONPATH=. python3 -m pytest scripts/tests -q` plus
|
||||
`ruff` for anything under `scripts/`, `.claude/`, `.husky/` or `.gitea/`, the BOM check for
|
||||
touched `.cs` (`process.bom-format-detection-recipe`), and live-E2E on the slot's port for a
|
||||
write path or UI change (`testing.live-e2e-prepush-timing`).
|
||||
2. **Reviewers** read the worktree diff (`git diff origin/main...HEAD`) and run the gate in their
|
||||
own worktrees. A round carrying any `blocking` or `should-fix` finding goes back to the fixer
|
||||
regardless of the lens's own verdict word (a `should-fix` is a real defect by definition); nits
|
||||
alone end the loop. A round in which every lens failed is an error, never a clean round. After
|
||||
two fix rounds an open finding stops the workflow before the push and the orchestrator decides.
|
||||
The same filter applies to the post-rebase round on the pushed head. In a resume, the existing
|
||||
PR's stale head and body are the expected state until the finisher pushes, never a finding. The
|
||||
loop ends on a clean round, never on "round one's findings are fixed".
|
||||
3. **Finisher**: fetch; if `origin/main` moved, rebase, re-run the gate, and compare the patch-id
|
||||
before and after — a changed patch (a conflict resolved, an artifact regenerated) sends the
|
||||
branch through one more review round before the verdict, because the reviewed tree must be the
|
||||
pushed tree. Then **one push**, `git push -u origin <branch>`; open the PR with `fixes #N` for
|
||||
every issue in the bundle, the review history the workflow recorded (one line per round, quoted
|
||||
verbatim — the finisher never describes a fix it did not see), the cross-family review status,
|
||||
and the session trailers; arm the CI
|
||||
monitor on the head sha; post the `## Closing record` on each issue with the evidence for every
|
||||
`## Done-when` box, ticking none.
|
||||
4. **Orchestrator**: read the review evidence, not the summaries. Send the PR back for anything
|
||||
that lets a route or test pass having done nothing. Tick each box whose evidence holds, then
|
||||
re-read the head sha immediately before posting `scripts/post-review-verdict.sh <pr> MERGEABLE
|
||||
<note>` naming the reviewers, the rounds and the workflow's returned `cross_family` status. Read
|
||||
that field, not only `error`: a cross-family failure in the post-rebase round leaves the branch
|
||||
pushed and the run successful, and only `cross_family` says the substitute also failed; a
|
||||
rubric-class PR in that state gets no verdict until a cross-family or substitute review of the
|
||||
pushed head has run. Tick the review box. Wait for CI — a `cancelled` job
|
||||
reads as `failure` at the combined status endpoint, so resolve it via the run's jobs
|
||||
(`ci.cancelled-is-not-a-verdict`).
|
||||
5. **Merge through the Gitea merge tool with the full head sha**; the consent hook derives consent
|
||||
from the ticked boxes, the sha-bound verdict and green CI and auto-grants
|
||||
(`release.merge-consent-autogrant`). A merge that `asks` is a state the orchestrator could not
|
||||
derive, so derive it — never answer the prompt from memory. Then `DELETE /issues/{n}/labels/100`
|
||||
on each issue, `git worktree remove`, refill the slot.
|
||||
|
||||
A rebase changes the head sha and voids the verdict; a fix after the push is a new commit, never an
|
||||
amend (`process.pr-routine-sequence`). A branch that conflicts after its push goes back to the fixer
|
||||
for a rebase pushed with `--force-with-lease` — the one sanctioned rewrite, scoped in
|
||||
`process.orchestrated-session` — another gate run and a fresh review of the rebased head. Push a
|
||||
branch behind `origin/main` and the pre-push hook refuses it (`release.format-as-you-touch-rebase`).
|
||||
|
||||
## Resuming a paused branch
|
||||
|
||||
A branch with a PR whose verdict is stale (head moved), whose CI is red, or that Gitea reports
|
||||
unmergeable is resumed by `ersatztv-resume-branch.js` with a brief holding the done condition, the
|
||||
last findings and the orchestrator's context. Its fixer rebases first, re-runs the gate, sits in the
|
||||
same review loop, and the finisher pushes with `--force-with-lease`; the orchestrator posts a fresh
|
||||
verdict for the new head. A resumed session finishes every open branch before it picks anything new.
|
||||
|
||||
## Hangs and incidents
|
||||
|
||||
- A `dotnet test` or E2E run with no output for ten minutes is a hang, not a slow test. Kill it by
|
||||
the PID you started, gently; re-run once before diagnosing.
|
||||
- Under an API incident (`https://status.claude.com/api/v2/status.json`), mechanical finishers and
|
||||
probe-driven reviews run on whichever model is healthy; large implementation waits. Back off
|
||||
before retrying.
|
||||
- A workflow that returns an empty or unexpected result is read from its `journal.jsonl` before it
|
||||
is re-run; a resumed run replays the unchanged prefix from cache.
|
||||
- Before any stop: the H12 audit (`process.issue-qualification-audit`),
|
||||
`scripts/refresh-shared-checkout.sh` (`session.shared-checkout-refresh`), and a handoff memory
|
||||
naming every open branch, its worktree, its PR and its next step.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Orchestrator prompt (paste unchanged into a fresh Claude Code session)
|
||||
|
||||
/goal @docs/handoffs/chicorytv-issue-queue.md You are the orchestrator and referee, not an implementer: read the kickoff and docs/handoffs/orchestration.md, then run the queue in parallel through subagents and workflows until you are told to stop or the eligible queue is empty, and run /done at the end.
|
||||
|
||||
Way of working:
|
||||
|
||||
- Size every subagent to its task; this is a rule, not a preference, because value per token is what the session is judged on. Picking and refuting: sonnet, medium. Recon on a large issue, implementation, fixing and correctness review: Opus at high (xhigh for a lock, threading or migration fix). Fable is for orchestrating and for the frontier escalations the kickoff lists, never for implementing. Small, well-specified fixes and mechanical finishing (rebase, push, PR body, label clearing): sonnet, medium. Conformance review: sonnet, high. Cross-family review through `codex exec` for the rubric's risk classes (locks, auth, API write paths, migrations, more than ~150 changed C# lines); when Codex cannot run, the workflow substitutes a cold same-family review-only agent and states the substitution in the PR body and its return, and your verdict note repeats it — a substitution is never silent. State the model and effort in every launch and revisit them when an incident degrades a model.
|
||||
- You never pick, claim, code or push yourself. A picker (sonnet, medium; `.claude/workflows/ersatztv-pick-next.js` with the taken list as args) runs `scripts/select-queue.sh`, applies the kickoff's claim and bundle rules to live Gitea state, and two refuters try to overturn it; you accept, or take a refuter's better pick when its rule is right. Then one issue-build workflow per pick, from `.claude/workflows/ersatztv-issue-build.js`, passing the session's commit trailer as `trailer`.
|
||||
- Before picking anything, finish what is already open: every open non-Renovate PR and every `in-progress` issue with a branch is a paused branch, resumed through `.claude/workflows/ersatztv-resume-branch.js` from a JSON brief.
|
||||
- Keep at most one .NET-building issue in flight (a docs or Python issue may run beside it), each in its own worktree under `~/orca/workspaces/ersatztv/wt-<n>` on branch `<n>-<slug>`, cut from a fresh `origin/main` by absolute path, never under `/tmp`, and each with its own E2E port passed as `port`. Refill a slot as soon as one merges. Pass every running issue's files as the avoid list so two agents never edit one file; a pick that needs a file another slot holds waits for that slot.
|
||||
- Each implementer claims its own issue after the four-way check (open PRs, remote branches, comments predating the label, a fresh fetch), with the `in-progress` label and a claiming comment; rebases onto a fresh `origin/main`, runs the local gate and sits inside the two-lens review loop BEFORE its single push; its finisher opens a PR whose body says `fixes #N` for every issue in the bundle and states the cross-family review status, and posts the `## Closing record` on each issue with the evidence per `## Done-when` box, ticking nothing — a box ticked by the agent it certifies is self-consent.
|
||||
- You referee: read the verdicts and the evidence, send a PR back for anything that lets a route or test pass having done nothing, tick each Done-when box whose evidence holds, re-read the head sha, then post the verdict with `scripts/post-review-verdict.sh <pr> MERGEABLE <note>` and tick the review box. Merge only through the Gitea merge tool with the full head sha, so the consent hook derives consent from the ticked boxes, the sha-bound verdict and green CI. A rebase voids the verdict: the finisher rebases once before the push and reports whether the patch changed; a changed patch gets one more review round before you post. After the merge clear the `in-progress` label and remove the worktree.
|
||||
- One .NET slot means two build pipelines in its review round (the correctness lens and, on a rubric change whose Codex runner cannot run, the fallback reviewer, one after the other); `process.build-concurrency-limits` holds — under 20% free RAM launch nothing that builds, under 10% pause — so read `memory_pressure -Q` and swap before every launch; live-E2E runs one at a time per worktree on the slot's own port, and a busy port is reported, never taken over. Kill only PIDs you started, gently. A `dotnet test` or E2E run silent for ten minutes is a hang.
|
||||
- Reviewers never delete anything outside a scratch directory they created under `/private/tmp` and never build a path with `..` segments; the auto-mode classifier is the last line, not the first.
|
||||
- Under an API incident (watch `https://status.claude.com/api/v2/status.json`), move mechanical finishers and probe-driven reviews to whichever model is healthy and park large implementation until it clears; back off before retrying.
|
||||
- Report to the user only what changes what they would do next: merged and closed issues, a send-back and why, a hang, an incident. Before any stop: the H12 audit (`process.issue-qualification-audit`), `scripts/refresh-shared-checkout.sh`, and a handoff memory naming every open branch, its worktree, its PR and its next step, so a resumed session finishes those before picking anything new.
|
||||
@@ -133,7 +133,7 @@ classifications differ; otherwise the strictest applies and the Note names the e
|
||||
| `scripts/ci-peak-anon.sh` | `N/A` | Reads no live remote state — samples the runner's local cgroup `memory.stat`/`memory.peak`. |
|
||||
| `scripts/ci-prove-ban-detects.sh` | `N/A` | Reads no live remote state — mutates a local workflow copy and runs pytest against the local checkout. |
|
||||
| `scripts/ci-step-ran.sh` | `N/A` | Reads no live remote state — reads runner-supplied env vars and local marker files it wrote itself. |
|
||||
| `scripts/ci-toolchain-image-resolves.sh` — registry manifest read for the pinned toolchain tag | `UNSAFE-KNOWN` | Reads a MUTABLE identifier (a registry tag) with nothing re-checking it before the `container:` jobs pull, so a tag deleted between the preflight and the pull is reported as present. Graded `UNSAFE-KNOWN` rather than `N/A` deliberately: nothing proceeds on the strength of the read — it can only turn its own job red, which is not nothing (the merge-consent hook denies on the COMBINED status, ersatztv#598) but is not authorization either — while a stale PASS is read by a human as "the image is fine", which is an assertion about remote state this file exists to grade. The residual is bounded by what it degrades to: a stale pass leaves exactly the pre-#772 behaviour (five jobs failing at pull), never anything that proceeds on the strength of the read. The opposite error is closed by the EXIT CODE rather than by wording: an unusable credential, an unverifiable answer (after retries) and an HTTP 200 whose body is not a manifest all FAIL the job. THE TRAP, since warning on those and exiting 0 is the natural way to write this check: a missing `curl`, a moved registry or a DNS change all land there, and a green-with-a-warning job is indistinguishable from a healthy pin forever after — "the check could not run" presenting as "the pin is fine", which is precisely what this row would then be asserting falsely. |
|
||||
| `scripts/ci-toolchain-image-resolves.sh` — registry manifest read for the pinned toolchain tag | `UNSAFE-KNOWN` | Reads a MUTABLE identifier (a registry tag) with nothing re-checking it before the `container:` jobs pull, so a tag deleted between the preflight and the pull is reported as present. Graded `UNSAFE-KNOWN` rather than `N/A` deliberately: nothing proceeds on the strength of the read — it can only turn its own job red, which is not nothing (the merge-consent hook denies on the COMBINED status, ersatztv#598) but is not authorization either — while a stale PASS is read by a human as "the image is fine", which is an assertion about remote state this file exists to grade. The residual is bounded by what it degrades to: a stale pass leaves exactly the pre-#772 behaviour (five jobs failing at pull), never anything that proceeds on the strength of the read. The opposite error is closed by the EXIT CODE rather than by wording: an unverifiable answer (after retries), an HTTP 200 whose body is not a manifest, and all three shapes of refused anonymous read — a `401`/`403` surviving a bearer the run actually obtained, a `401` whose token leg produced no bearer, and a first-read `403` that never entered the token leg at all — every one of them FAILS the job. The script names no credential (ersatztv#885, `ci.pr-route-carries-no-stored-credential`): it runs on the `pull_request` route, where the YAML is head-supplied, so it reads this public package through the registry's anonymous Bearer-token flow instead. That changes nothing about the grade — the tag it reads is mutable either way. THE TRAP, since warning on those and exiting 0 is the natural way to write this check: a missing `curl`, a moved registry or a DNS change all land there, and a green-with-a-warning job is indistinguishable from a healthy pin forever after — "the check could not run" presenting as "the pin is fine", which is precisely what this row would then be asserting falsely. |
|
||||
| `scripts/set-provider.sh` | `N/A` | Reads no live remote state — sets local `dotnet user-secrets` values. |
|
||||
| `scripts/__init__.py` | `N/A` | Empty package marker — executes nothing. |
|
||||
| `scripts/scripted-schedules/entrypoint.py` — `ScriptedScheduleApi.get_context(build_id)`, then `define_content` / `reset_playout` / `build_playout` against the same live server | `UNSAFE-KNOWN` | A genuine read-then-act over live ErsatzTV state. The context is fetched, handed to user-supplied script functions that mutate the playout, and re-fetched after a reset with nothing pinning either read — a concurrent build or edit between them is invisible. Accepted because it runs inside a single scripted-schedule build the server itself serialises per playout, and because the API exposes no version or ETag on the context to compare against; the honest bound is that the blast radius is one playout's content, reversible by rebuilding. |
|
||||
|
||||
@@ -404,6 +404,66 @@ double-invoke for the re-arm) **and** the *integration* (mock the hook module an
|
||||
component actually read `current` — and saw `false` — when the late response landed). Verify each by
|
||||
removing the mechanism and confirming the test fails.
|
||||
|
||||
## 3c. A write failure must outlive the dismissible surface that started it (#830)
|
||||
|
||||
`Dialog`, `SlideOver` and `ConfirmDialog` are all dismissible **mid-request** through three paths
|
||||
that never consult a busy flag: Escape and a backdrop/scrim click (both via `useOverlayBehavior`),
|
||||
and the header close button. Disabling the footer Cancel button — which nearly every dialog here
|
||||
does — looks like it closes that hole and does not.
|
||||
|
||||
So an error rendered from the dismissible component's own state has nowhere to go once the user
|
||||
dismisses it. The request still runs, still fails, and the screen reloads unchanged: the user reads
|
||||
that as success. Guarding the `setError` with an is-mounted check (§3b) is necessary but **not
|
||||
sufficient** — it makes the drop deliberate rather than accidental, which is still a drop.
|
||||
|
||||
**Use `useDismissSafeError` (`web/src/hooks.ts`).** It renders the message inline while the surface
|
||||
is mounted — the better surface, since it keeps the user's context — and hands it to a
|
||||
caller-supplied `onFailed` once the surface is gone:
|
||||
|
||||
```tsx
|
||||
const { inlineError, reportFailure, setInlineError } = useDismissSafeError(onFailed);
|
||||
// ...
|
||||
} catch (err) {
|
||||
reportFailure(messageFromXError(err, 'Unable to …'));
|
||||
}
|
||||
```
|
||||
|
||||
The surviving surface belongs to the **parent**, so `onFailed` is a prop contract, not a rendering
|
||||
decision. `CollectionsScreen` reports into its screen-level `role="alert"` banner — the only wired
|
||||
site today. The other candidate surface is the `notice` + `Toast` pair that `MediaBrowseScreen` and
|
||||
`SearchScreen` already use for success. There is no global toast host in this SPA; do not add one
|
||||
for a single screen.
|
||||
|
||||
The two are **not equivalent**, so pick deliberately if you wire the second: `Toast` is
|
||||
`role="status"` (polite, announced less assertively than `role="alert"`) and each screen has a
|
||||
single `notice` slot, so a later success Toast would overwrite a pending failure one. Nothing
|
||||
diverts to those screens today — they receive no reporting callback.
|
||||
|
||||
**Both halves of the outcome must reach the surviving caller**, and they are not the same kind of
|
||||
callback. Report the OUTCOME (`onAdded` / `onDone` / `onFailed`) unguarded — gating a success
|
||||
callback on an is-mounted check makes a completed write silent, exactly like the failure case. But
|
||||
gate the DISMISS request (`onClose`): it means "close me", and after dismissal "me" is whatever the
|
||||
user opened next. The four `media/addTo/` dialogs currently gate BOTH (measured on
|
||||
`AddToCollectionDialog`: `onAdded` called 0 times after dismissal) and are tracked in #877 —
|
||||
separating the two there needs the parent screens to stop nulling the dialog themselves, so it is a
|
||||
design change rather than a one-line fix. When you add a success callback to a dialog, add its
|
||||
failure counterpart, and keep the close decision distinct from the report.
|
||||
|
||||
Make the reporting prop **required** where the host screen has a surface for it —
|
||||
`AddItemsDialog.onAddFailed` is, so the failure cannot be dropped by forgetting to wire it. Make it
|
||||
optional only when some host genuinely has nowhere to report (the `media/addTo/` layer's eventual
|
||||
one will be, because `MediaDetailScreen` wires no outcome callbacks at all); there, an omitted
|
||||
`onFailed` drops the failure exactly as today — a known gap, not coverage (#877). Rationale and the
|
||||
full call-site sweep: `docs/decisions/records/spa/dismissible-write-failure-reporting.md`.
|
||||
|
||||
**Pin the arm the CALL SITE reaches, not only the hook's.** A unit test of `useDismissSafeError`
|
||||
proves the inline branch exists; it cannot see a call site that reports through `onFailed` directly
|
||||
and so pushes the message onto the parent banner while the surface is still up — behind a
|
||||
`createPortal` panel with `aria-modal="true"`, i.e. covered for sighted users and hidden from AT.
|
||||
Add an integration assertion for each arm: fail the write with the surface still open and assert the
|
||||
message is inside `[role="dialog"]`, and fail it again across a dismissal and assert the message is
|
||||
on the screen and not in a dialog.
|
||||
|
||||
## 4. API client modules
|
||||
|
||||
One file per domain in `web/src/api/`, e.g. `logs.ts`, `blocks.ts`, `playouts.ts`. Pattern (see
|
||||
@@ -583,6 +643,34 @@ safe) and mirrors `onError`'s requirements exactly: pass a **stable** `onPlaying
|
||||
since it too sits in the attach effect's dependency array and an unstable identity would restart the
|
||||
stream every render.
|
||||
|
||||
**Autoplay (#554).** `HlsPlayer` takes an optional `muted` prop, **defaulting to `false`**. Muted
|
||||
media is the one thing browsers autoplay without user activation, so passing it makes the
|
||||
`video.play()` kick above succeed even when `MANIFEST_PARSED` arrives past the browser's transient
|
||||
user-activation window (~5s in Chrome) — the failure mode a slow-starting channel (unbounded manifest
|
||||
`maxTimeToFirstByteMs`, see above) hits, leaving a player sitting at "starting" over a black frame
|
||||
with no indication the operator just needs to press play. It is a **per-consumer** choice rather than
|
||||
a player-wide default because muting costs audio-by-default: the channel-preview panel
|
||||
(`ChannelPreviewPanel.tsx`) opts in — it answers "does this channel work right now", and starting
|
||||
beats being audible — while the playback-troubleshooting screen
|
||||
(`PlaybackTroubleshootingScreen.tsx`) must stay unmuted, since verifying the audio side of an FFmpeg
|
||||
profile is part of its job. `controls` is always on either way, so a muted player can be unmuted by
|
||||
hand.
|
||||
|
||||
For the residual case where autoplay is rejected anyway (a stricter browser policy or an extension),
|
||||
`HlsPlayer` also takes an optional `onAutoplayBlocked()`. It fires **only** on an autoplay-policy
|
||||
rejection — a `DOMException` named `NotAllowedError`; a `play()` promise rejected under any other
|
||||
name (notably `AbortError`, which is what a pending `play()` interrupted by a teardown produces, and
|
||||
a consumer's own Retry does exactly that) is not reported, or the caller would put a cause in front
|
||||
of the operator that did not happen. Like `onError`/`onPlaying` it sits in the attach effect's
|
||||
dependency array, so pass a `useCallback`; it is purely additive and omitting it is safe. The
|
||||
channel-preview panel wires it to a "press play" hint whose visibility is the render guard
|
||||
`state === 'starting'` **alone**, so a second clear in `onPlaying` would be a guard no test could
|
||||
distinguish. That holds because the panel's paths back to `starting` either clear the flag themselves
|
||||
(Retry, a channel change) or cannot be reached while it is set — the forced-preview opt-in re-enters
|
||||
`starting` without clearing, but its button renders only while the panel has not started, and the
|
||||
only thing that un-starts it is the channel reset that clears the flag. The panel never claims the
|
||||
fault fixed itself; the hint mirrors an honest, still-not-playing state.
|
||||
|
||||
**Resolving a `/iptv/*` src under JWT auth (#552).** Before feeding an `/iptv/*` manifest URL to
|
||||
`HlsPlayer`, pass it through `withIptvToken(url)` (`web/src/media/iptvToken.ts`): under a JWT-enabled
|
||||
deployment it appends the short-lived `?access_token=` the `/iptv/*` scheme requires (the `ctv-session`
|
||||
@@ -603,6 +691,13 @@ page's action row) and the `AddToCollectionDialog` / `AddToPlaylistDialog` / `Ad
|
||||
on grid screens is an explicit "Select" toggle (see `docs/decisions.md` 2026-07-10 for the rationale
|
||||
and the accepted deviations from Blazor).
|
||||
|
||||
Each dialog in this layer is unmounted the moment it is dismissed, so an outcome the screen does not
|
||||
receive is an outcome the user never sees. As of 2026-08-29 the layer has **no failure channel at
|
||||
all** — `AddToMenu` exposes `onDone` and no counterpart — and its dialogs gate the success callback
|
||||
on their own unmount guard, so a write that settles after dismissal reports nothing in either
|
||||
direction. Adding one is #877; see §3c for the mechanism and for why the close decision has to stay
|
||||
separate from the report.
|
||||
|
||||
## 5d. Client-local preferences: `localStorage`, namespaced `ctv-*` keys
|
||||
|
||||
Per-browser UI preferences (theme, an auth token, a screen's remembered page size) live in
|
||||
|
||||
+81
-9
@@ -20,7 +20,7 @@ before adding tests, not just `docs/contributing.md` §8 (which now just points
|
||||
| `ErsatzTV.Scanner.Tests` | Library scanning: scan handlers, folder scanners, NFO readers | Handler tests substitute the folder scanners + `ILibraryRepository` and assert the resulting repository writes (e.g. `ScanLocalLibraryHandlerTests` pins which `LastScan` levels a scan records — ersatztv#264). Fakes/`Testably` back the file-system-facing scanners. ~1,485 tests (approximate on purpose — an exact count goes stale on every PR that adds one). Additionally contains `Core/FFmpeg/TranscodingTests` — `[Explicit]` + `[Combinatorial]`, so it never runs in CI or a plain `dotnet test` (it needs real ffmpeg/hardware) and contributes 0 to that count; run it by name when touching the transcoding pipeline. |
|
||||
| `ErsatzTV.Architecture.Tests` | Layering rules via NetArchTest.eNhancedEdition | Core↛Infra/App/EF; FFmpeg↛all; App↛concrete providers. 5 tests. See `docs/contributing.md` §1. |
|
||||
| `ErsatzTV.FFmpeg.Tests` | FFmpeg command construction | Build a pipeline, assert the exact rendered arg string (`PipelineBuilderBaseTests.cs`). |
|
||||
| `web/` (vitest) | React SPA unit tests | Run alongside typecheck + build (see below). Collects `src/**`, `web/scripts/**` *and* `web/vite-plugins/**`, but deliberately **excludes** `web/e2e/**` (the Playwright specs — vitest's default `**/*.spec.*` glob would otherwise run them under jsdom). Two files have git prerequisites since ersatztv#819. `web/src/api/pageSizeCallSites.guard.test.ts` needs a git **checkout** AND, through it, the **binary** — it derives its file population from `git ls-files` via `web/vite-plugins/trackedSourceFiles.ts` rather than a directory walk, and refuses rather than falling back. `web/vite-plugins/trackedSourceFiles.realgit.test.ts` needs the **binary** but no checkout: it builds its own temp repository to prove that derivation by executing it. So it is not checkout-versus-binary — supplying a `.git` alone would not let either run. Every other file runs fine with neither. `docker/Dockerfile`'s web-build stage runs the suite with exactly those two `--exclude`d, because its context carries no `.git` and `node:22-bookworm-slim` ships no git — and the two exclusions overlap rather than divide — both files need the binary. |
|
||||
| `web/` (vitest) | React SPA unit tests | Run alongside typecheck + build (see below). Collects `src/**`, `web/scripts/**` *and* `web/vite-plugins/**`, but deliberately **excludes** `web/e2e/**` (the Playwright specs — vitest's default `**/*.spec.*` glob would otherwise run them under jsdom). Some files have git prerequisites since ersatztv#819, and the set is not fixed — ersatztv#883 added one. `web/src/api/pageSizeCallSites.guard.test.ts` and `web/src/api/completeAnnotations.guard.test.ts` need a git **checkout** AND, through it, the **binary**: they derive their file population from `git ls-files` via `web/vite-plugins/trackedSourceFiles.ts` rather than a directory walk, and refuse rather than falling back. `web/vite-plugins/trackedSourceFiles.realgit.test.ts` needs the **binary** but no checkout — it builds its own temp repository to prove that derivation by executing it. So it is not checkout-versus-binary: supplying a `.git` alone would not let any of them run. **The suite therefore runs only where git is present, and `docker/Dockerfile` is not such a place** — its web-build stage builds the SPA and does not test it (ersatztv#887). Enumerating the git-dependent files as Docker `--exclude`s was tried and REVERSED: that list is a population nothing derives, it went stale the first time a guard was added, and the resulting red is unreachable on a PR — `Build & push image (amd64)` is `if: github.event_name != 'pull_request'` — so it landed on `main` and on the release tag instead. The image is gated on `docker-build.yml`'s `test` job running the whole suite on a real checkout, held by `scripts/tests/test_image_build_delegates_the_spa_suite.py`. |
|
||||
| `web/e2e/` (Playwright) | UI-interactive E2E flows against a **live** instance | Not a unit suite and **not** part of `npm test` — needs a running server, so it runs via `scripts/e2e-ui.sh` (boots its own fresh instance) and in CI as a step of the `functional-e2e` job. Headless Chromium, `serial`, `retries: 0`. Scope rule: assert only what the curl harness structurally cannot. See `docs/e2e-local.md` → "UI-E2E harness". |
|
||||
|
||||
## Golden-file nets
|
||||
@@ -35,14 +35,10 @@ Three golden-file suites guard the highest-value, most-subtle output:
|
||||
produces over a pinned build window. Covers the **Classic** (`PlaybackOrder.Chronological`),
|
||||
**Block**, and **Sequential (YAML)** builders. The **Scripted** *end-to-end pipeline* is excluded from this
|
||||
net — `ScriptedPlayoutBuilder` runs a user-authored external process that drives the engine over HTTP, which
|
||||
the in-memory harness can't pin; that integration harness is tracked in ersatztv#563. The scheduling
|
||||
*behavior* those scripts drive, though, lives in the in-process `SchedulingEngine` (the HTTP controller is a
|
||||
1:1 pass-through) and IS directly testable — `SchedulingEngineTests` news it up with substitutes, and
|
||||
`ContentEnumeratorBuilderTests` (ersatztv#395) is the direct regression net over the enumerator-construction
|
||||
helper the Scripted and Sequential/YAML engines now share (decision:
|
||||
`testing.scripted-playout-golden-deferred`). The **Sequential** case
|
||||
(`Sequential_yaml`, ersatztv#381)
|
||||
builds from a committed YAML fixture (`Goldens/Fixtures/sequential-schedule.yml`) instead of a
|
||||
the in-memory harness can't pin. Scripted is instead covered in-process at two levels, described under
|
||||
"Scripted playout coverage" below (decision: `testing.scripted-engine-in-process-net`, ersatztv#563).
|
||||
The **Sequential** case (`Sequential_yaml`, ersatztv#381) builds from a committed YAML fixture
|
||||
(`Goldens/Fixtures/sequential-schedule.yml`) instead of a
|
||||
`ProgramSchedule`; it is TZ-independent (the `count`/`all`/`duration` handlers do UTC-only arithmetic —
|
||||
it passes, not skips, under a non-UTC `TZ`) so needs no `Assume` guard. A third case,
|
||||
`Classic_clock_padded` (ersatztv#77), locks clock-boundary padding: a `FillerMode.Pad` +
|
||||
@@ -67,6 +63,71 @@ diff during normal test runs means the code changed the output — regenerating
|
||||
away hides the change instead of surfacing it. Only a human who has confirmed the change is
|
||||
intentional should regenerate.
|
||||
|
||||
## Scripted playout coverage
|
||||
|
||||
Scripted playout is characterized in-process at two levels, and the transport is deliberately not covered
|
||||
at all (ersatztv#563, decision `testing.scripted-engine-in-process-net`).
|
||||
|
||||
| Where | Covers |
|
||||
| --- | --- |
|
||||
| `ErsatzTV.Core.Tests/Scheduling/Engine/SchedulingEngineTests.cs` | The engine build API a script drives: `AddCollection`, `AddCount`, `AddAll`, `AddDuration` (stop-before-end and trim), `PadUntilExact`, EPG guide-group locking, per-item `PlayoutHistory`, the `IsDone` no-progress halt and its reset, and the `GetAnchor`/`RestoreOrReset` round-trip a Continue build resumes from. Substituted repositories, no database. |
|
||||
| `ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests.cs` | A committed script fixture (`Controllers/Fixtures/scripted-build.json`), bound with the production body-binder configuration (`ApiJsonSettings`) and replayed through the real `ScriptedScheduleController` + `ScriptedPlayoutBuilderService.MockSession` + `SchedulingEngine`, with a pinned 13-item snapshot in the same line format the playout goldens use (both trimming actions target an instant *between* two content boundaries, so each one's `trim` argument reaches the engine's trim branch rather than sitting inert), plus the four adapter mappings: 404 on an unknown build id, 400 on an unparseable playback order, a silent fall back to `FillerKind.None` on an unparseable filler kind, and the engine's no-progress `InvalidOperationException` translated to a 400. |
|
||||
| `ErsatzTV.Core.Tests/Scheduling/ContentEnumeratorBuilderTests.cs` | The enumerator-construction helper the Scripted and Sequential/YAML engines share (ersatztv#395). |
|
||||
| `ErsatzTV.Tests/Serialization/ApiJsonSettingsTests.cs` | What the replay's standalone binder does and does not share with the one MVC runs, so the paragraph below stays a measurement rather than a claim. |
|
||||
|
||||
The expected snapshot is a string constant in the test rather than a fourth golden file: the golden
|
||||
harness lives in `ErsatzTV.Core.Tests`, which cannot reference a controller, and duplicating it would
|
||||
create a second action-to-engine mapping — the thing the single-mapping design exists to avoid.
|
||||
|
||||
**Not covered, and not scheduled to be** (measured 2026-09-05): the `Cli.Wrap` launch of the user's own
|
||||
program — exit code, the `PlayoutScriptedScheduleTimeoutSeconds` timeout, stdout capture; Kestrel and the
|
||||
`Startup` middleware, including the host/`Settings.UiPort` check that 404s a foreign request;
|
||||
`ApiAuthorizationFilter`, which fail-closes every mutating verb for an endpoint without
|
||||
`[SkipApiAuthorization]`; and MVC model binding as a *wrapper* — the input formatter, model validation
|
||||
(a non-nullable reference type picks up an implicit required check there) and the `[ApiController]`
|
||||
automatic 400 either produces before the action runs, since the replay hands each action an
|
||||
already-bound object. Hosting the real `Startup` would drag in the whole DI graph, a
|
||||
hand-rolled minimal host would test a transport the product does not have, and the script is
|
||||
user-authored by definition — so any committed script is a stand-in either way. The decision record
|
||||
names the concrete blind spot this leaves, and it is tracked as ersatztv#913.
|
||||
|
||||
The *serializer* inside that binding wrapper is covered rather than scoped out. The replay deserializes
|
||||
fixture bodies through `ErsatzTV.Serialization.ApiJsonSettings`, the same function `Startup` hands to
|
||||
`AddNewtonsoftJson`, and two tests witness that choice through the replay's own bind helper. The
|
||||
fixture's own bodies parse the same way under every plausible replacement, so each test carries a body
|
||||
built to separate one of them:
|
||||
|
||||
| Replacement the test separates the production binder from | The body that separates them |
|
||||
| --- | --- |
|
||||
| `System.Text.Json` with web defaults | A body omitting the C# `required` member `collection`. Newtonsoft has no notion of `required` and deserializes it to a default; System.Text.Json rejects the body outright. `Production_Body_Binder_Ignores_Required_Members` asserts both halves. |
|
||||
| a bare `new JsonSerializerSettings()` — still Newtonsoft, but without the production configuration | An explicit `"order": null`. `NullValueHandling.Ignore` keeps `ContentCollection.Order` at its declared `"shuffle"`; Newtonsoft's own `Include` default writes the null through, and `AddCollection`'s `Enum.TryParse` then returns a 400. `Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null` asserts the production side end to end. |
|
||||
|
||||
Both are statements about the serializer and stop there — what MVC validation does with such a body
|
||||
belongs to the wrapper named above. What each test does to a suite when the binder is actually swapped
|
||||
was measured while building it and is recorded in ersatztv#563, not here: under
|
||||
`testing.mutation-claims-are-executed` such a sentence is a `CLAIMS` entry in
|
||||
`scripts/tests/mutation_manifest.py` that executes every run or it is not written, and that harness runs
|
||||
pytest, so an NUnit proof cannot be declared in it.
|
||||
|
||||
What is shared with production is the *configuration*, not the settings **object**. MVC applies it to
|
||||
settings it has already configured; `ApiJsonSettings.Create()`, which every test outside the pipeline
|
||||
uses, applies it to a bare one. Measured 2026-09-05, the standalone object therefore keeps Newtonsoft's
|
||||
`MaxDepth` of 64 instead of MVC's stricter 32 and lacks MVC's `ProblemDetailsConverter` and
|
||||
`ValidationProblemDetailsConverter`; `MissingMemberHandling`, `TypeNameHandling` and `DateParseHandling`
|
||||
match. Neither gap can reach a scripted request body — the DTOs nest two levels and are never a
|
||||
`ProblemDetails` — which is what makes `Create()` usable in a test at all, and no test may generalize
|
||||
from it to "production" beyond that. `ErsatzTV.Tests/Serialization/ApiJsonSettingsTests.cs` pins the
|
||||
whole delta in both directions, so it fails rather than rots if either object moves.
|
||||
|
||||
`ApiJsonSettings` exists so that binder is *defined* once; it is not a drift detector, and the difference
|
||||
is worth stating because it bounds what the suite can promise. Nothing here observes
|
||||
`Startup.ConfigureServices`, and a byte-equal hand-copy of `Apply` is behaviourally indistinguishable
|
||||
from calling it — so the extraction removes the duplicate rather than detecting drift in one. A mirror
|
||||
that has *lost* something is what the suites separate: the table above on the read path, and
|
||||
`OpenApiSerializerContractTests` on the write path, whose four cases assert the camelCase keys
|
||||
`CustomContractResolver` produces and nothing else in the configuration supplies. Drift confined to
|
||||
`ReferenceLoopHandling` or the `StringEnumConverter` is separated by neither.
|
||||
|
||||
## Timezone independence
|
||||
|
||||
The suite is timezone-independent (ersatztv#24). When constructing test `PlayoutItem`s, always
|
||||
@@ -74,6 +135,17 @@ set a real `Start` (e.g. `startState.CurrentTime.UtcDateTime`) — never rely on
|
||||
`DateTime.MinValue`, which underflows `DateTimeOffset.MinValue` once a non-UTC local offset is
|
||||
applied (`StartOffset` calls `ToLocalTime()`). CI runs in UTC; local runs may not.
|
||||
|
||||
**Which `SchedulingEngine` calls are safe to put in a fixture** (audited 2026-09-05). `AddCount`,
|
||||
`AddAll`, `AddDuration` and `PadUntilExact` preserve the instant — items are always written as
|
||||
`_state.CurrentTime.UtcDateTime`, and `PadUntilExact`'s `ToLocalTime()` changes the offset the state
|
||||
carries, not the moment. `WaitUntil(TimeOnly)` and `PadUntil(string)` are **not** safe: both read the
|
||||
LOCAL day and time-of-day off `CurrentTime` and rebuild a target from them. `PadToNext` is safe only
|
||||
until something localizes `CurrentTime` — it reads `.Year`/`.Month`/`.Day`/`.Hour`/`.Minute` in whatever
|
||||
offset that value happens to carry, so a fixture that calls it after `PadUntilExact`, `WaitUntilExact` or
|
||||
a Continue anchor becomes TZ-sensitive by ordering rather than by call. The scripted fixtures therefore
|
||||
use Chronological order and only the instant-preserving instructions; they pass, rather than skip, under
|
||||
`TZ=UTC`, `America/New_York`, `Australia/Lord_Howe` and `Asia/Kathmandu`.
|
||||
|
||||
## Running tests
|
||||
|
||||
Full .NET gate:
|
||||
|
||||
@@ -10,8 +10,8 @@ narrative detector is a string predicate over prose, and `docs/defect-shapes-773
|
||||
withdrawn `test_review_verdict_vocabulary_parity.py` — six review rounds, then deleted — is the
|
||||
empirical case). Do not convert this into a gate; the decision record says no in as many words.
|
||||
|
||||
WHY PYTHON AND NOT SHELL. The first implementation hand-parsed `git diff -U0` output in bash by
|
||||
matching line prefixes, and cold review demonstrated four separate defects in that one parser: the
|
||||
WHY PYTHON AND NOT SHELL. Hand-parsing `git diff -U0` output in bash by matching line prefixes
|
||||
produced four separate defects in that one parser: the
|
||||
`\\ No newline at end of file` marker was counted as content, an added line whose own text began
|
||||
`++ ` was eaten by the `+++ ` header arm, `core.quotePath` hid non-ASCII paths, and `read` dropped a
|
||||
final unterminated line. Those are four instances of one mistake — deciding what a diff line IS from
|
||||
@@ -54,7 +54,25 @@ except Exception: # noqa: S110 — a stdout that cannot be reconfigured is not
|
||||
# the 35 have more than one commit, so revisions do happen — which is precisely the case where the
|
||||
# rule's remedy (move it to the commit message) is still available. The exemption costs the only
|
||||
# reach the detector has and buys only quiet in a sweep a person runs deliberately.
|
||||
EXEMPT_PREFIXES = ("docs/decisions/",)
|
||||
#
|
||||
# `scripts/tests/fixtures/` is exempt for the same reason one level down (#876): it holds COPIES of
|
||||
# decision records and other test DATA, which carry whatever phrasing the test under them needs and
|
||||
# are not an artifact anyone edits for a reader. Exempting it is also what keeps the depth
|
||||
# measurement in the record true — its record copies sit at four to six slashes.
|
||||
EXEMPT_PREFIXES = ("docs/decisions/", "scripts/tests/fixtures/")
|
||||
|
||||
# The PROCESS corpus (#876): hooks, workflows, scripts and their tests, scanned regardless of
|
||||
# extension because the artifacts there are shell, YAML, Python and jq, not Markdown. It is in the
|
||||
# population on the #812 argument run forward — `--diff` sees only ADDED lines, and this is where
|
||||
# narrative is being ADDED: 287 of the 453 sites the #876 sweep found outside the docs corpus were
|
||||
# under 30 days old (measured 2026-09-03 at `fb5592971`), against a `docs/superpowers/**` that had
|
||||
# not moved since 2026-07-23. `web/` and the C# projects are left out on the same measurement: 3 of
|
||||
# the 74 PATTERNS-matching sites lived there, across roughly 4,600 tracked files.
|
||||
PROCESS_PREFIXES = (".claude/", ".gitea/", ".husky/", "scripts/")
|
||||
|
||||
# Exempt by NAME, not by prefix: this file and its test carry the phrasings as pattern and fixture,
|
||||
# so they would be permanent hits — the false positive that makes an advisory check stop being read.
|
||||
EXEMPT_FILES = ("scripts/check-doc-narrative.py", "scripts/tests/test_check_doc_narrative.py")
|
||||
|
||||
# Session-narrative phrasings. Deliberately narrow: each is first person or names a revision of THIS
|
||||
# artifact. Broad words that also appear in legitimate dated history ("previously", "was wrong") are
|
||||
@@ -84,14 +102,19 @@ WARNING = (
|
||||
|
||||
|
||||
def is_scanned_path(path: str) -> bool:
|
||||
"""The population: `docs/**/*.md` minus `docs/decisions/**`, plus root-level `*.md`.
|
||||
"""The population: `docs/**/*.md` minus `docs/decisions/**`, plus root-level `*.md`, plus every
|
||||
tracked file under `PROCESS_PREFIXES` regardless of extension, minus `EXEMPT_FILES`.
|
||||
|
||||
Stated positively and in one place so the record's `mechanics:` can quote it exactly. Skills,
|
||||
`web/`, and other nested markdown outside `docs/` are deliberately NOT in scope.
|
||||
Stated positively and in one place so the record's `mechanics:` can quote it exactly. `web/`,
|
||||
C# source, and nested markdown outside `docs/` and the process prefixes are deliberately NOT in
|
||||
scope. This is a PATH predicate; `run_all` additionally restricts the population to tracked
|
||||
regular files, because a symlink's content is its target, not the artifact.
|
||||
"""
|
||||
if not path.endswith(".md"):
|
||||
if path in EXEMPT_FILES or any(path.startswith(p) for p in EXEMPT_PREFIXES):
|
||||
return False
|
||||
if any(path.startswith(p) for p in EXEMPT_PREFIXES):
|
||||
if any(path.startswith(p) for p in PROCESS_PREFIXES):
|
||||
return True
|
||||
if not path.endswith(".md"):
|
||||
return False
|
||||
return path.startswith("docs/") or "/" not in path
|
||||
|
||||
@@ -163,10 +186,19 @@ def added_lines(diff: str):
|
||||
p = raw[4:]
|
||||
# `/dev/null` on the new side means the file was DELETED. Stated honestly: this arm
|
||||
# is DEFENSIVE, not load-bearing — a deletion contributes no `+` lines, so nothing is
|
||||
# yielded for it either way, and removing this arm reddens no test. It is kept because
|
||||
# `path` should never name a file the added lines do not belong to. A
|
||||
# yielded for it either way. Measured rather than asserted, and re-measured on every
|
||||
# run: `test_check_doc_narrative.py` stays green with this arm removed, a declared
|
||||
# claim in `scripts/tests/mutation_manifest.py` (ersatztv#881). The named file is the
|
||||
# scope that is actually executed — a claim over every test in the repository would
|
||||
# be wider than anything re-taking it. The arm is kept because `path` should never
|
||||
# name a file the added lines do not belong to. A
|
||||
# `--diff-filter=d` on the git call was removed rather than kept beside it: a second
|
||||
# mechanism nobody can witness failing is how a duplicate guard hides its twin.
|
||||
# Git terminates the `+++` filename with a TAB when the path contains a space, and a
|
||||
# path carrying that tab matches nothing in `is_scanned_path` — the file is dropped
|
||||
# SILENTLY, the same scanned-0 channel the config pins above close. Witnessed by a
|
||||
# positive control with a space in the name.
|
||||
p = p.split("\t", 1)[0]
|
||||
path = None if p == "/dev/null" else (p[2:] if p.startswith("b/") else p)
|
||||
elif raw.startswith("+"):
|
||||
lineno += 1
|
||||
@@ -212,14 +244,30 @@ def run_diff(base: str, out: list[str]) -> int:
|
||||
|
||||
def run_all(out: list[str]) -> int:
|
||||
# Population from `git ls-files`, never a filesystem walk — an untracked scratch file is not
|
||||
# part of the corpus (#778).
|
||||
rc, listing = git("ls-files", "-z", "--", "*.md")
|
||||
# part of the corpus (#778). The STAGE listing, because the population is tracked REGULAR files
|
||||
# (mode 100644/100755): a tracked symlink (120000) is a path the predicate admits whose worktree
|
||||
# content is its TARGET — a directory (`.claude/skills/jellyfin`, a cross-repo skill link) or a
|
||||
# file that may sit outside the population — and a gitlink (160000) is a submodule pointer with
|
||||
# no content of its own. `--diff` carries no such filter: a gitlink's diff content is a sha, and
|
||||
# a symlink's is its target PATH, scanned like any other added line — a phrasing inside a path
|
||||
# name would draw an advisory warning on the link, which is harmless. The stage listing emits
|
||||
# one row PER STAGE for an unmerged path, so modes are gathered per path first: a path is opened
|
||||
# once, and only when EVERY stage is a regular blob — in an add/add conflict between a symlink
|
||||
# and a regular file, the worktree holds one of them and a per-row test would let the regular
|
||||
# row authorise opening the other.
|
||||
rc, listing = git("ls-files", "-s", "-z")
|
||||
if rc != 0:
|
||||
print("doc-narrative: could not list tracked files — SCANNED NOTHING.")
|
||||
return -1
|
||||
modes: dict[str, set[str]] = {}
|
||||
for entry in listing.split("\0"):
|
||||
if not entry:
|
||||
continue
|
||||
meta, _, path = entry.partition("\t")
|
||||
modes.setdefault(path, set()).add(meta.split(" ", 1)[0])
|
||||
scanned = 0
|
||||
for path in listing.split("\0"):
|
||||
if not path or not is_scanned_path(path):
|
||||
for path, path_modes in modes.items():
|
||||
if not all(m.startswith("100") for m in path_modes) or not is_scanned_path(path):
|
||||
continue
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||||
|
||||
@@ -119,8 +119,8 @@ case $(printf '%s' "$verdict" | jq -r '.verdict // ""' 2>/dev/null || true) in
|
||||
# NOT given separate arms — because this script's contract publishes exactly five class words and
|
||||
# `unreadable` already means precisely "no comparison was made", which is true of both.
|
||||
#
|
||||
# A separate `unreadable)` arm printing the same word was written first and then deleted: cold
|
||||
# review measured it as a no-op (removing it left the suite green), and the merge hook's own
|
||||
# A separate `unreadable)` arm printing the same word is a measured no-op (removing it
|
||||
# left the suite green), and the merge hook's own
|
||||
# comment condemns exactly that — an arm no observation can distinguish is a comment with syntax.
|
||||
# The hook can afford two arms because it has two distinct REASON STRINGS to hand an operator;
|
||||
# here there is one output word, so a second arm would be decoration.
|
||||
|
||||
@@ -127,8 +127,8 @@ comments=$(cat)
|
||||
# "convention not adopted" and downgrade a hard block into an ask.
|
||||
#
|
||||
# Each body is emitted as a JSON STRING on its own line (newlines escaped by JSON), so comment
|
||||
# boundaries are carried out-of-band. An earlier version joined bodies with a literal sentinel line;
|
||||
# a comment containing that sentinel could forge a boundary, reset fence state mid-body, and expose a
|
||||
# boundaries are carried out-of-band. Joining bodies with a literal sentinel line instead would let
|
||||
# a comment containing that sentinel forge a boundary, reset fence state mid-body, and expose a
|
||||
# verdict that was still inside an unclosed fence. In-band delimiters are forgeable by whoever writes
|
||||
# the data — and here that is anyone who can comment on the PR.
|
||||
# The shape is asserted IN jq so a payload that isn't an array of comment objects with STRING bodies
|
||||
@@ -189,7 +189,7 @@ verdicts=$(printf '%s\n' "$encoded" | while IFS= read -r encoded_body; do
|
||||
# A shorter or different marker while a fence is open is content, so it neither closes nor prints.
|
||||
outside=$(printf '%s\n' "$body" | awk '
|
||||
{
|
||||
# Raw HTML blocks are the third code-block form (#629 round 5): <pre>, <code> and HTML
|
||||
# Raw HTML blocks are the third code-block form (#629): <pre>, <code> and HTML
|
||||
# comments all render their contents literally, so a verdict inside one is an example, not an
|
||||
# approval. Tracked as a simple depth/marker count rather than parsed — the direction of error
|
||||
# is to strip MORE, which can only ever withhold approval.
|
||||
|
||||
@@ -63,16 +63,21 @@ if [ "$merge_tree" != "$pr_tree" ]; then
|
||||
fi
|
||||
|
||||
# Merge tree matches the PR head tree exactly. Confirm that PR head was actually validated green
|
||||
# before trusting it -- query the Gitea combined commit status API. Auth is required (private
|
||||
# instance); a missing/failing/non-success response always falls through to skip=false.
|
||||
if [ -z "${ETV_STATUS_AUTH:-}" ]; then
|
||||
echo "ETV_STATUS_AUTH not set; cannot verify PR head status; running full validation (safe default)"
|
||||
emit false
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# before trusting it -- query the Gitea combined commit status API.
|
||||
#
|
||||
# THE READ IS ANONYMOUS (ersatztv#885, `ci.pr-route-carries-no-stored-credential`). The three jobs
|
||||
# that call this run on the `pull_request` route as well as on the main push, and Gitea resolves a
|
||||
# `pull_request` run from the PR HEAD -- so those jobs may hold no stored secret, and the
|
||||
# `ETV_STATUS_AUTH` they used to carry is gone. `timothy/ersatztv` is public and answers this GET
|
||||
# unauthenticated (measured 2026-09-04: HTTP 200 carrying the combined state). No credential
|
||||
# override is kept in its place: the URL below names ONE instance, that instance is public, so an
|
||||
# override would be a code path with no caller -- and an unusable one (":", the shape an absent
|
||||
# secret interpolates to) would draw a 401 and turn a working read into a permanent skip=false.
|
||||
#
|
||||
# Every failure direction here is safe: a missing, failing or non-success response falls through to
|
||||
# skip=false, which re-runs validation. Nothing about this step can cause a skip that was not earned.
|
||||
status_url="http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv/commits/${pr_head}/status"
|
||||
status_json="$(curl -sf -u "$ETV_STATUS_AUTH" "$status_url" || true)"
|
||||
status_json="$(curl -sf "$status_url" || true)"
|
||||
if [ -z "$status_json" ]; then
|
||||
echo "status API request for PR head ${pr_head} failed; running full validation (safe default)"
|
||||
emit false
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# Prove — at runtime, every run — that the delimiter ban actually DETECTS a delimiter (ersatztv#767).
|
||||
#
|
||||
# WHY THIS EXISTS AS A SHELL STEP RATHER THAN A TEST. The `scan` job's guarantee used to be a pytest
|
||||
# that asserted the ban command works. Review then disarmed the whole gate with ONE new file at the
|
||||
# repo root — a `pytest.ini` carrying `addopts = -k "not delimiter_banned"`, or a `conftest.py` with
|
||||
# that asserted the ban command works. DEMONSTRATED: ONE new file at the repo root disarms the whole gate — a
|
||||
# `pytest.ini` carrying `addopts = -k "not delimiter_banned"`, or a `conftest.py` with
|
||||
# `pytest_collection_modifyitems` — which deselects the ban test AND the tests guarding it, leaving
|
||||
# every job green while a delimiter sits in `build`'s `Smoke` body and the image publishes. That tier
|
||||
# sits above every `env:` a test can reconstruct, and above the tests themselves: anything living
|
||||
@@ -11,8 +11,8 @@
|
||||
#
|
||||
# IT RUNS IN THE REAL CHECKOUT, NOT A COPY, and that is the whole point. An isolated copy does not
|
||||
# inherit the repo-root `pytest.ini`/`conftest.py`/`pyproject.toml` that a disarm would live in, so a
|
||||
# copy-based proof reports the ban healthy while the job's actual invocation is deselected — measured,
|
||||
# and it is why the first version of this script was wrong. Same cwd, same rootdir, same config and
|
||||
# copy-based proof reports the ban healthy while the job's actual invocation is
|
||||
# deselected — measured. Same cwd, same rootdir, same config and
|
||||
# the same BAN INVOCATION as the run being vouched for — not the same command line, since the
|
||||
# workflow step also runs the wiring tests and this runs only the ban file. Only the workflow file
|
||||
# differs, and only for a moment.
|
||||
@@ -34,8 +34,8 @@
|
||||
# passing (the ban is not enforcing), pytest failing for a NON-test reason (collection error, nothing
|
||||
# collected — cannot prove anything), and pytest failing on some OTHER test while the ban test itself
|
||||
# was deselected. Only the ban test's `[build]` parametrisation failing counts as proof. Both weaker
|
||||
# readings were live bugs in earlier drafts of this file: "non-zero means it noticed" reported OK on a
|
||||
# collection error, and a later draft still fell through to OK when an unrelated test reddened.
|
||||
# readings have been live bugs here: "non-zero means it noticed" reported OK on a
|
||||
# collection error, and the other still fell through to OK when an unrelated test reddened.
|
||||
#
|
||||
# NOTE ON THE OPENER: this file must never contain the literal two-character expression opener, both
|
||||
# because `scan`'s own run bodies are asserted delimiter-free and because a file containing it is the
|
||||
@@ -81,8 +81,8 @@ detected=$?
|
||||
set -e
|
||||
|
||||
# THREE OUTCOMES, EXHAUSTIVELY, and only the last one is a pass. "Non-zero means it noticed" is not
|
||||
# good enough — that was this script's own first bug, and an earlier draft of this very block still
|
||||
# fell through to OK when pytest exited 1 for a DIFFERENT test's failure while the ban test was
|
||||
# good enough, and neither is "exit 1 means the ban test failed": both fall through to
|
||||
# OK when pytest exits 1 for a DIFFERENT test's failure while the ban test was
|
||||
# deselected. Requiring the exact expected failure is what makes this a proof rather than a liveness
|
||||
# check. (`[build]` because the ban is parametrised per job, and `build` is the one whose dropped
|
||||
# `Smoke` publishes an unsmoked image.)
|
||||
|
||||
@@ -48,8 +48,8 @@
|
||||
# `review-verdict.yml`, which runs WITHOUT a `container:`. `test` and `migrations` run INSIDE the CI
|
||||
# toolchain image, so their `/tmp` is the job container's own and starts empty. That follows from
|
||||
# `container:`, NOT from a measurement: the build-lane probe confirmed only that `RUNNER_TEMP` is
|
||||
# `/tmp` here (the marker landed at `/tmp/etv-ci-steps-ran-test-1910-1`) — it says nothing about the
|
||||
# directory being private or empty, and an earlier draft of this comment cited it as though it did.
|
||||
# `/tmp` here (the marker landed at `/tmp/etv-ci-steps-ran-test-1910-1`) — it says nothing about
|
||||
# the directory being private or empty.
|
||||
# The fresh container is what actually rules out a stale marker here; the keying is defence in depth.
|
||||
#
|
||||
# It is kept because container-per-job is a property of how the lane is configured today, not a
|
||||
@@ -74,8 +74,8 @@ EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
# NO SILENT FALLBACK FOR THE RUN IDENTITY — found by cold review. The first version defaulted to
|
||||
# `nojob`/`norunid`/`1`, and those are REUSABLE: with `GITHUB_RUN_ID` unset, every run on the host
|
||||
# NO SILENT FALLBACK FOR THE RUN IDENTITY. Defaulting to `nojob`/`norunid`/`1` is
|
||||
# REUSABLE: with `GITHUB_RUN_ID` unset, every run on the host
|
||||
# would share ONE marker file, so a leftover from any earlier run would satisfy the guard on a run
|
||||
# whose step was dropped. A silent PASS — the exact failure the keying exists to remove, reintroduced
|
||||
# by the code meant to implement it.
|
||||
@@ -95,8 +95,8 @@ EOF
|
||||
# then simply read off run 1916: `Marker identity: job=test run=1916 attempt=1 (from the runner)`
|
||||
# and the same for `migrations`. Both required jobs, on the lane that matters.
|
||||
#
|
||||
# That measurement is what promoted it from warn-and-default to REQUIRED, which is why the residual
|
||||
# this comment used to describe — a rerun inheriting attempt 1's markers — no longer exists FOR THE
|
||||
# That measurement is what promoted it from warn-and-default to REQUIRED, which is why the rerun
|
||||
# residual — a rerun inheriting attempt 1's markers — no longer exists FOR THE
|
||||
# CONTAINER JOBS. If a future runner stops exporting any of the three, every job reddens with a
|
||||
# message naming the variable; that is loud, instantly diagnosable, and the correct direction for a
|
||||
# required check.
|
||||
@@ -117,8 +117,8 @@ marker_path() {
|
||||
if [ -n "$missing" ]; then
|
||||
# NOTHING IS PRINTED TO STDOUT HERE, and that is load-bearing rather than style: this
|
||||
# function's stdout IS its return value (it is always called inside `$( )`), so a notice
|
||||
# printed here is captured INTO the path. An earlier revision did exactly that and both
|
||||
# sub-commands then failed on a nonexistent directory. Caught by
|
||||
# printed here is captured INTO the path. Printing one makes both
|
||||
# sub-commands fail on a nonexistent directory. Pinned by
|
||||
# test_a_degraded_run_IDENTITY_*, which is why that test asserts on the exit status and on
|
||||
# the absence of any marker file rather than only on the message.
|
||||
echo "::error::ci-step-ran.sh cannot identify this run —${missing} not set. The marker path would fall back to a name other runs also use, and a stale marker would make the dropped-step guard PASS on a run whose step never executed (ersatztv#756). Refusing rather than degrading to a reusable name." >&2
|
||||
@@ -200,7 +200,7 @@ cmd_assert() {
|
||||
echo "Skip gate fired (docs_only='${ETV_DOCS_ONLY:-}', already_validated='${ETV_REVALIDATE_SKIP:-}') — the gated steps were not expected to run."
|
||||
fi
|
||||
|
||||
# RE-CHECKED AFTER GATING, not only on argv — found by cold review, which reproduced it:
|
||||
# RE-CHECKED AFTER GATING, not only on argv — reproduced:
|
||||
# `ETV_DOCS_ONLY=true … assert --always --gated foo` printed "All 0 expected step(s) executed"
|
||||
# and exited 0. The argv check above cannot see that, because the set is emptied by the gate, not
|
||||
# by the caller. Unreachable with today's argv (both jobs pass `--always detect revalidate`), but
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
#
|
||||
# WHY THIS EXISTS. `docker-build.yml` pins its five `container:` jobs to an immutable
|
||||
# `ersatztv-ci:<sha>`. Between 2026-08-11 and 2026-08-13 that tag was deleted from the Gitea
|
||||
# registry and every one of those jobs — including BOTH required contexts — died after 1-2s with
|
||||
# registry and every one of those jobs — including BOTH required contexts — died at image pull,
|
||||
# before running a step, with
|
||||
#
|
||||
# Error response from daemon: failed to resolve reference "…/ersatztv-ci:<the pinned sha>": not found
|
||||
#
|
||||
@@ -18,24 +19,42 @@
|
||||
# failure into one actionable message, which is all a consumer of someone else's registry can do.
|
||||
#
|
||||
# WHY IT DOES NOT GATE THE CONTAINER JOBS with `needs:`. Serialising five jobs behind a checkout +
|
||||
# one curl would tax every green run to speed up the rare red one, and the container jobs already
|
||||
# fail fast (1-2s) when the pull fails. This runs in PARALLEL: the diagnosis is present the moment
|
||||
# anyone looks, and the happy path pays nothing.
|
||||
# one curl would tax every green run to speed up the rare red one, and a container job that cannot
|
||||
# pull its image fails at the pull, before it runs a step, so it wastes no work waiting to be told.
|
||||
# This runs in PARALLEL: the diagnosis is present the moment anyone looks, and the happy path pays
|
||||
# nothing.
|
||||
#
|
||||
# UNKNOWN IS NOT A PASS, and this is where the first draft was wrong. It warned and exited 0 on
|
||||
# every answer that was not 200 or 404, which makes "curl is missing from this runner", "the
|
||||
# UNKNOWN IS NOT A PASS. Warning and exiting 0 on every answer that is not 200 or
|
||||
# 404 makes "curl is missing from this runner", "the
|
||||
# registry moved", and "DNS changed" all indistinguishable from a healthy pin — a job that is green
|
||||
# forever having checked nothing, in a file whose header claims the opposite. Unknown answers are
|
||||
# RETRIED (they are usually transient) and then FAIL. The message stays distinct from the deleted
|
||||
# case: "could not verify" and "IS GONE" send an operator to different places.
|
||||
#
|
||||
# Env (all optional except the credential; the defaults are the live values):
|
||||
# IT READS THE REGISTRY ANONYMOUSLY, AND THAT IS LOAD-BEARING RATHER THAN INCIDENTAL. This job runs
|
||||
# on the `pull_request` route, where the workflow YAML is supplied by the PR head, so it may hold no
|
||||
# stored secret (ersatztv#885, `ci.pr-route-carries-no-stored-credential`). A naive unauthenticated
|
||||
# GET of a manifest is a 401 for every tag, present or deleted — which is why this used to demand a
|
||||
# credential — but that 401 is a Bearer CHALLENGE, and this registry issues an anonymous pull token
|
||||
# for a public package against it. Measured 2026-09-04: the token endpoint answers 200 with no
|
||||
# credential, that token reads the pinned manifest and its config blob (200), and a tag that does
|
||||
# not exist answers 404 rather than 401 — so the deleted-tag diagnosis this whole script exists for
|
||||
# survives the change. What does NOT survive it is the `ersatztv-ci` PACKAGE ceasing to be
|
||||
# anonymously pullable: the token leg then refuses, and this fails loudly with a message that names
|
||||
# that cause rather than reporting could-not-tell. That is the package and not this repo — the
|
||||
# package is linked to no repository (measured 2026-09-05: every version of it reports
|
||||
# `"repository": null`), so `timothy/ersatztv` going private would not take the pull token away.
|
||||
# What that WOULD take away is the unauthenticated commit-status GET in
|
||||
# scripts/ci-detect-already-validated.sh, which is a different dependency with the opposite failure
|
||||
# direction: it falls through to `skip=false` and stays green. Do not cite this script's loudness
|
||||
# for that one.
|
||||
#
|
||||
# Env (all optional; the defaults are the live values):
|
||||
# ETV_CI_REGISTRY registry host:port (default 192.168.1.95:3000)
|
||||
# ETV_CI_IMAGE_REPO package path inside the registry (default timothy/ersatztv-ci)
|
||||
# ETV_CI_WORKFLOW workflow file to read the pin from (default .gitea/workflows/docker-build.yml)
|
||||
# ETV_CI_ATTEMPTS tries per pin before an unknown becomes a failure (default 3)
|
||||
# ETV_CI_RETRY_SECONDS pause between those tries (default 5)
|
||||
# ETV_REGISTRY_AUTH user:pass — REQUIRED; the registry rejects anonymous reads with 401
|
||||
set -euo pipefail
|
||||
|
||||
registry="${ETV_CI_REGISTRY:-192.168.1.95:3000}"
|
||||
@@ -52,60 +71,157 @@ fail() { printf '::error::ci-toolchain-image-resolves: %s\n' "$*" >&2; exit 1; }
|
||||
pins=$(grep -oE 'ersatztv-ci:[0-9a-f]+' "$workflow" | cut -d: -f2 | sort -u || true)
|
||||
[ -n "$pins" ] || fail "no ersatztv-ci pin found in $workflow — if the grep pattern stopped matching, fix it here and in pr-checks.yml::ci-image-pin together"
|
||||
|
||||
# No credentials is NOT a pass. An unauthenticated read of this registry is a 401 for every tag,
|
||||
# present or deleted, so a run without them would report "cannot tell" for a live pin and for a
|
||||
# deleted one alike — the shape where a guard reports green having checked nothing.
|
||||
#
|
||||
# The EMPTY-halves check is the one that matters in CI and is easy to miss: an absent secret does
|
||||
# not arrive here as an unset variable. `ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ ... }}`
|
||||
# interpolates a missing secret to the empty string, so the job passes the non-empty string ":".
|
||||
# Testing only the unset case would leave the production shape uncovered.
|
||||
auth="${ETV_REGISTRY_AUTH:-}"
|
||||
[ -n "$auth" ] || fail "ETV_REGISTRY_AUTH (user:pass) is unset, so the registry cannot be queried — this check refuses to report a pass it did not establish"
|
||||
case "$auth" in
|
||||
*:*) ;;
|
||||
*) fail "ETV_REGISTRY_AUTH must be user:pass, got a value with no ':' — the registry cannot be queried and this check refuses to report a pass it did not establish" ;;
|
||||
esac
|
||||
[ -n "${auth%%:*}" ] && [ -n "${auth#*:}" ] \
|
||||
|| fail "ETV_REGISTRY_AUTH has an empty half (user or password) — this is what an ABSENT REGISTRY_USER/REGISTRY_PASSWORD secret interpolates to, not a credential. Fix the secrets rather than reading an unauthenticated 401 as could-not-tell."
|
||||
|
||||
accept='application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json'
|
||||
attempts="${ETV_CI_ATTEMPTS:-3}"
|
||||
retry_seconds="${ETV_CI_RETRY_SECONDS:-5}"
|
||||
rc=0
|
||||
|
||||
# One GET, returning "<code> <is-a-manifest>". The body is fetched rather than a HEAD sent, because
|
||||
# The anonymous pull token, acquired once and reused for every pin. Empty until the registry
|
||||
# challenges. `token_leg_done` separates "not tried yet" from "tried", which is what lets the
|
||||
# refusal arms below report what the run actually DID rather than what it might have done.
|
||||
#
|
||||
# `token_leg_retryable` separates the two ways the leg can come back empty, and they are not the
|
||||
# same event. A token endpoint that ANSWERED and named no token — or a challenge naming no realm, or
|
||||
# no challenge at all — has told us something, and asking it again cannot change the answer: that is
|
||||
# the registry genuinely refusing anonymous reads, and it is asked once per RUN. A token endpoint
|
||||
# that could not be REACHED, or that answered 5xx, told us nothing; it is the same transport blip the
|
||||
# manifest read absorbs, so it is retried on the same `ETV_CI_ATTEMPTS` budget. Without that split
|
||||
# the two legs of one read had opposite flake tolerances — measured 2026-09-05 on the predecessor of
|
||||
# this commit with `ETV_CI_ATTEMPTS=3`, an unreachable token endpoint failed after 1 token call and
|
||||
# 1 manifest call while a flaky manifest read got 3 retries — and this job's red denies a merge (the
|
||||
# consent hook reads the COMBINED status, ersatztv#598), so a one-second token-endpoint outage
|
||||
# blocked a merge until someone re-ran it.
|
||||
token=""
|
||||
token_leg_done=0
|
||||
token_leg_retryable=0
|
||||
token_leg_attempts=0
|
||||
headers=$(mktemp)
|
||||
trap 'rm -f "$headers"' EXIT
|
||||
|
||||
# One directive out of a `Www-Authenticate: Bearer realm="…",service="…"` challenge. The realm is
|
||||
# read from the challenge rather than assumed, so a registry that moves its token endpoint is
|
||||
# followed instead of guessed at; `service` is optional (this registry issues a token without it,
|
||||
# measured 2026-09-04) and is passed through when the challenge names one.
|
||||
#
|
||||
# The directive NAME is matched in any case, through a character class generated from the key, and
|
||||
# the header name likewise (below): RFC 7235 §2.1 makes auth-param names case-insensitive and RFC
|
||||
# 9110 §5.1 makes field names case-insensitive, so a registry answering `WWW-Authenticate: Bearer
|
||||
# Realm="…"` is spelling this legally. Matching one spelling would send that challenge down the
|
||||
# "named no realm" arm and `fail` the job with the wrong diagnosis — loud, but pointing an operator
|
||||
# at a token endpoint that is fine. The VALUE keeps its case: a realm URL is case-sensitive.
|
||||
challenge_field() {
|
||||
local key="$2" pattern="" index char
|
||||
for ((index = 0; index < ${#key}; index++)); do
|
||||
char=${key:index:1}
|
||||
pattern="${pattern}[${char}$(printf '%s' "$char" | tr '[:lower:]' '[:upper:]')]"
|
||||
done
|
||||
printf '%s' "$1" | sed -n "s/.*[ ,]$pattern=\"\([^\"]*\)\".*/\1/p" | head -1
|
||||
}
|
||||
|
||||
# The token leg. Returns non-zero on every shape that leaves us without a bearer — no challenge, no
|
||||
# realm in it, a token endpoint that will not answer, or an answer carrying no token. Each of those
|
||||
# is "could not establish anonymous access", which the caller turns into a REFUSAL rather than a
|
||||
# could-not-tell: an empty token would otherwise fall through to a second 401 and read as an
|
||||
# ordinary auth failure with no cause named. It also SORTS them, into `token_leg_retryable`: only
|
||||
# the shapes where the endpoint said nothing at all are worth asking again.
|
||||
acquire_token() {
|
||||
local challenge realm service url resp body http
|
||||
token_leg_done=1
|
||||
token_leg_retryable=0
|
||||
token_leg_attempts=$((token_leg_attempts + 1))
|
||||
challenge=$(tr -d '\r' < "$headers" |
|
||||
awk -F: 'tolower($1) == "www-authenticate" { sub(/^[^:]*:[[:space:]]*/, ""); print; exit }')
|
||||
[ -n "$challenge" ] || return 1
|
||||
realm=$(challenge_field "$challenge" realm)
|
||||
[ -n "$realm" ] || return 1
|
||||
service=$(challenge_field "$challenge" service)
|
||||
url="$realm?scope=repository:$image_repo:pull"
|
||||
# Spelled as a full `if` rather than `[ … ] && …`: as a bare statement the latter returns the
|
||||
# test's exit status, which is 1 whenever `service` is absent — a legal challenge shape.
|
||||
if [ -n "$service" ]; then url="$url&service=$service"; fi
|
||||
# The status code is read for the same reason the manifest read reads it: it is the only thing
|
||||
# that separates "this endpoint REFUSED" from "this endpoint was not there", and only the second
|
||||
# is worth a second ask. A `curl` that fails outright (DNS, connect, reset) leaves no response at
|
||||
# all and lands in the same class.
|
||||
resp=$(curl -s -w '\n%{http_code}' "$url") || resp=""
|
||||
if [ -z "$resp" ]; then
|
||||
token_leg_retryable=1
|
||||
return 1
|
||||
fi
|
||||
http=${resp##*$'\n'}
|
||||
body=${resp%$'\n'*}
|
||||
case "$http" in
|
||||
000|5??) token_leg_retryable=1; return 1 ;;
|
||||
esac
|
||||
token=$(printf '%s' "$body" | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
|
||||
[ -n "$token" ] || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# One GET, recording the HTTP code and whether the body is a manifest. The body is fetched rather
|
||||
# than a HEAD sent, because
|
||||
# HTTP 200 alone does not mean "the manifest is there": a proxy, a captive login page or an error
|
||||
# document all answer 200 with a body that is not a manifest, and a check that reads only the status
|
||||
# line reports those as "resolves". A manifest always carries `schemaVersion`, so the body is matched
|
||||
# for it — with a shell `case`, so nothing depends on jq being installed and no pipeline can invert
|
||||
# the result on a large body.
|
||||
probe() {
|
||||
local url="$1" resp code body
|
||||
# `-u` puts the credential in argv, visible to `ps` for the length of the call — and this job has
|
||||
# no `container:`, so that is the shared host. Kept because it is the shape every other curl caller
|
||||
# in scripts/ already uses (`ci-detect-already-validated.sh`, `pr-changed-files.sh`,
|
||||
# `select-queue.sh`, `issue-qualification-audit.sh`): fixing one site would leave the class intact
|
||||
# and the codebase inconsistent. The class is tracked in ersatztv#821.
|
||||
resp=$(curl -s -w '\n%{http_code}' -u "$auth" -H "Accept: $accept" "$url") || resp=""
|
||||
[ -n "$resp" ] || { printf '000 no\n'; return 0; }
|
||||
#
|
||||
# It ASSIGNS `code`/`is_manifest` rather than printing them, and so does `probe` — because the token
|
||||
# and the "already tried the token leg" flag must survive from one pin to the next, and a
|
||||
# `$(probe …)` command substitution runs in a SUBSHELL whose assignments are discarded. Reading the
|
||||
# answer through a subshell would silently re-run the whole two-leg exchange for every pin.
|
||||
request() {
|
||||
local url="$1" resp body
|
||||
if [ -n "$token" ]; then
|
||||
resp=$(curl -s -w '\n%{http_code}' -D "$headers" -H "Authorization: Bearer $token" -H "Accept: $accept" "$url") || resp=""
|
||||
else
|
||||
resp=$(curl -s -w '\n%{http_code}' -D "$headers" -H "Accept: $accept" "$url") || resp=""
|
||||
fi
|
||||
if [ -z "$resp" ]; then
|
||||
code="000"
|
||||
is_manifest="no"
|
||||
return 0
|
||||
fi
|
||||
code=${resp##*$'\n'}
|
||||
body=${resp%$'\n'*}
|
||||
case "$body" in
|
||||
*'"schemaVersion"'*) printf '%s yes\n' "$code" ;;
|
||||
*) printf '%s no\n' "$code" ;;
|
||||
*'"schemaVersion"'*) is_manifest="yes" ;;
|
||||
*) is_manifest="no" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The two legs of an anonymous registry read: the plain GET, and — only if it is challenged and the
|
||||
# leg is not already settled — the token exchange followed by ONE retry carrying the bearer. A 401
|
||||
# that survives the retry is left as a 401 and refused by the caller; it is never rounded off to
|
||||
# could-not-tell, because the cause (this package is no longer readable without a credential) sends
|
||||
# an operator somewhere quite different from a flaky registry.
|
||||
#
|
||||
# "Settled" is `token_leg_done` AND NOT `token_leg_retryable`: a leg abandoned because its endpoint
|
||||
# could not be reached settled nothing, so the next attempt asks again.
|
||||
probe() {
|
||||
request "$1"
|
||||
if [ "$code" = "401" ] && { [ "$token_leg_done" -eq 0 ] || [ "$token_leg_retryable" -eq 1 ]; }; then
|
||||
if acquire_token; then
|
||||
request "$1"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
code=""
|
||||
is_manifest="no"
|
||||
for pin in $pins; do
|
||||
url="http://$registry/v2/$image_repo/manifests/$pin"
|
||||
attempt=1
|
||||
while : ; do
|
||||
read -r code is_manifest <<EOF
|
||||
$(probe "$url")
|
||||
EOF
|
||||
probe "$url"
|
||||
case "$code" in
|
||||
200|404|401|403) break ;;
|
||||
200|404|403) break ;;
|
||||
401)
|
||||
# A 401 is an answer — unless the only reason it still stands is a token leg whose endpoint
|
||||
# could not be reached, in which case it is an unknown wearing the first read's status code
|
||||
# and belongs in the retry class below with every other unknown.
|
||||
if [ "$token_leg_retryable" -eq 0 ]; then break; fi
|
||||
;;
|
||||
esac
|
||||
# Only the unknown answers are retried: 200/404 are answers, and an auth failure will not cure
|
||||
# itself. A transient registry is the common case for the rest, and absorbing it here is what
|
||||
@@ -134,7 +250,41 @@ EOF
|
||||
401|403)
|
||||
# `fail` rather than `rc=1`: unlike a 404, this says nothing about the pin, and it will say
|
||||
# the same thing about every remaining one. Abandoning the loop keeps the log to one cause.
|
||||
fail "the registry rejected these credentials (HTTP $code) for $registry/$image_repo:$pin, so the pin could not be checked. Fix REGISTRY_USER/REGISTRY_PASSWORD rather than reading this as a pass."
|
||||
#
|
||||
# THREE ways to get here, and they send an operator to three different places, so they are
|
||||
# worded apart — the same reason `404` and `could NOT VERIFY` are. Each message states only
|
||||
# what actually ran, because a message naming a step that did not happen is evidence for a
|
||||
# diagnosis nobody performed:
|
||||
#
|
||||
# * a refusal that survived a GOOD token is an answer about this PACKAGE, and since
|
||||
# ersatztv#885 it is not a preflight-only problem: every `container:` job pulls the same
|
||||
# image with no credential, so they fail at image pull too, including both required
|
||||
# contexts;
|
||||
# * a challenge that yielded no usable token is an infrastructure answer about the TOKEN
|
||||
# ENDPOINT;
|
||||
# * a FIRST-READ 403 never reached the token leg at all — `probe` enters it on a 401 only —
|
||||
# so nothing was ever asked for. This is an answer about ACCESS to the registry. Its
|
||||
# message says NO TOKEN WAS EVER REQUESTED rather than "the registry issued no challenge":
|
||||
# a 403 MAY carry a `Www-Authenticate` and this script would still not follow it, so only
|
||||
# the first is something the run observed. Note the boundary too: a 401 carrying no
|
||||
# challenge DOES enter the token leg and abandon it, so it lands in the arm above, not
|
||||
# this one. The branch order below is `token` first precisely so this case cannot borrow
|
||||
# either of the other two mechanisms.
|
||||
#
|
||||
# A token leg that ANSWERED is not retried, deliberately: `token_leg_done` is set before the
|
||||
# attempt and `token_leg_retryable` stays 0, so a registry genuinely refusing anonymous reads
|
||||
# is asked once per RUN rather than once per pin or once per attempt. A token endpoint that
|
||||
# could not be REACHED settled nothing and IS retried, on the same `ETV_CI_ATTEMPTS` budget the
|
||||
# manifest read uses — the two legs of one read must not have opposite flake tolerances when a
|
||||
# red here denies a merge. The message below reports how many token-leg attempts actually ran,
|
||||
# so the two cases are distinguishable in the log rather than only in this comment.
|
||||
if [ -n "$token" ]; then
|
||||
fail "the registry refused an ANONYMOUS read (HTTP $code) of $registry/$image_repo:$pin even after a Bearer token was obtained, so the pin could not be checked. Every container: job pulls this image without a credential too, so they will fail at image pull. Check that the ersatztv-ci package is still PUBLIC (it is linked to no repository, so this is the package's own visibility, not the repo's) — do not read this as a pass."
|
||||
fi
|
||||
if [ "$token_leg_done" -eq 1 ]; then
|
||||
fail "could NOT OBTAIN an anonymous pull token for $registry/$image_repo:$pin after $token_leg_attempts token-leg attempt(s) — the registry answered HTTP $code and the token leg produced none: either there was no Www-Authenticate challenge, or it named no realm, or the token endpoint did not answer with a token. The pin was NOT checked. Look at the registry's token endpoint, not at the pin."
|
||||
fi
|
||||
fail "the registry refused an ANONYMOUS read (HTTP $code) of $registry/$image_repo:$pin and NO TOKEN WAS EVER REQUESTED: the token leg is entered on a 401 only, so this answer was never followed as a Bearer challenge and the pin could not be checked. An outright refusal is an answer about ACCESS to the registry, not about the pin. Every container: job pulls this image without a credential too, so they will fail at image pull. Check that the ersatztv-ci package is still PUBLIC (it is linked to no repository, so this is the package's own visibility, not the repo's), and that nothing (a proxy, an ACL) is answering for the registry — do not read this as a pass."
|
||||
;;
|
||||
*)
|
||||
# NOT gone, and NOT a pass either. Deliberately worded apart from the 404 message: this sends
|
||||
|
||||
@@ -58,7 +58,7 @@ RECORD_CEILING_DEFAULT = 60
|
||||
# it is cutting into the bulk rather than marking a tail. See `ceiling_calibration` for why the fine
|
||||
# percentile claim is reported instead of asserted.
|
||||
#
|
||||
# The floor is NOT "at least one record" — that was the first draft and it was nearly unfalsifiable:
|
||||
# The floor is NOT "at least one record" — that is nearly unfalsifiable:
|
||||
# measured on the live corpus it accepted every ceiling from 39 to 229, including the ceiling of 200
|
||||
# this module's own docstring offered as the case it catches (one 230-line record keeps the count
|
||||
# nonzero). A 2% floor rejects 200/229/230 and still leaves ~5x headroom below today's 9.8%.
|
||||
@@ -441,10 +441,9 @@ def record_wing_files(records_dir: Path | None = None, archive_dir: Path | None
|
||||
`RECORDS_DIR.rglob` — so such a file IS a corpus source while being exempt from the guard.
|
||||
|
||||
So the exemption is by exact RELATIVE PATH, never by basename. The only entry is
|
||||
`archive/README.md`, a hand-written directory README that really does exist — an earlier
|
||||
version of this function excluded any wing-root `README.md` "since no such file exists today",
|
||||
which was simply false and would additionally have exempted a future `records/README.md`, i.e.
|
||||
reintroduced the very hole one directory over.
|
||||
`archive/README.md`, a hand-written directory README that really does exist — excluding any
|
||||
wing-root `README.md` "since no such file exists today" is simply false, and would additionally
|
||||
exempt a future `records/README.md`, i.e. reintroduce the very hole one directory over.
|
||||
"""
|
||||
records_dir = dl.RECORDS_DIR if records_dir is None else records_dir
|
||||
archive_dir = dl.ARCHIVE_DIR if archive_dir is None else archive_dir
|
||||
|
||||
+65
-20
@@ -37,12 +37,14 @@
|
||||
# etv_hook_fire_begin <hook-name> [label]
|
||||
#
|
||||
# SELF-LOCATED, and the `${CLAUDE_PROJECT_DIR:-...}` form this recipe used to show is BANNED —
|
||||
# `process.hook-resolves-inputs-from-repo-root` (ersatztv#858). This line is SOURCED, so whatever it
|
||||
# names runs as code inside the hook before the hook decides anything; a file of this name in an
|
||||
# env-var-designated tree can print a decision and exit 0. Measured on the merge-consent hook: it
|
||||
# granted the merge outright. The recipe is corrected here because the header is what a new hook
|
||||
# copies, so leaving it would keep minting the defect after ersatztv#891 sweeps the existing hooks.
|
||||
# Twelve hooks still carry the old form; that sweep is #891, not this line.
|
||||
# `process.hook-resolves-inputs-from-repo-root` (ersatztv#858, #891). This line is SOURCED, so
|
||||
# whatever it names runs as code inside the hook before the hook decides anything; a file of this
|
||||
# name in an env-var-designated tree can print a decision and exit 0. Measured on the merge-consent
|
||||
# hook: it granted the merge outright. The recipe matters because the header is what a new hook
|
||||
# copies, so a wrong one here keeps minting the defect. #891 swept the rest: every tracked hook now
|
||||
# carries this line byte-identically, and `test_hook_fire_log.py` — whose population comes from
|
||||
# `git ls-files`, so a new hook is covered the moment it exists — fails any that does not. The read
|
||||
# side below self-locates for the same reason.
|
||||
#
|
||||
# USAGE (read side):
|
||||
# scripts/hook-fire-log.sh report # this session (or every session, see below)
|
||||
@@ -90,8 +92,8 @@ etv_hook_fire_scrub() {
|
||||
# A STRICTER scrub for the one value that becomes a PATH. The record scrub above deliberately keeps
|
||||
# `/` and `.` — tool names like `mcp__gitea__x` and event paths read better with them — but those
|
||||
# are exactly the two characters that turn a session id into `../../escaped`. Passing a value
|
||||
# through a scrub is not the same as passing it through the RIGHT scrub, and the first version of
|
||||
# this fix reused the record scrubber and left the traversal wide open while reading as fixed.
|
||||
# through a scrub is not the same as passing it through the RIGHT scrub: reusing the record
|
||||
# scrubber here leaves the traversal wide open while reading as fixed.
|
||||
etv_hook_fire_scrub_component() {
|
||||
printf '%s' "${1:-}" | tr -c 'A-Za-z0-9_-' '_' | cut -c1-120
|
||||
}
|
||||
@@ -166,8 +168,8 @@ etv_hook_fire_classify() {
|
||||
fi
|
||||
[ -z "$d" ] && d=$(printf '%s' "$out" | sed -n 's/.*"decision"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
||||
|
||||
# A NON-CANONICAL VALUE IS RECORDED AS SUCH, not normalised into a valid one. An earlier version
|
||||
# lowercased whatever it found, so `"permissionDecision":"Deny"` was filed as a clean `deny` —
|
||||
# A NON-CANONICAL VALUE IS RECORDED AS SUCH, not normalised into a valid one. Lowercasing
|
||||
# whatever is found files `"permissionDecision":"Deny"` as a clean `deny` —
|
||||
# manufacturing a decision the harness may never have honoured. The documented values are
|
||||
# lowercase; anything else is a hook bug, and the log should say so rather than launder it.
|
||||
case "$d" in
|
||||
@@ -403,9 +405,8 @@ etv_hook_fire_flush() {
|
||||
# rescue path delivered 52 bytes where the hook wrote 53, and every JSON parser downstream
|
||||
# accepts the short form without complaint. Pinned by `test_stdout_is_replayed_BYTE_EXACT`.
|
||||
#
|
||||
# NO `2>/dev/null` ON THE `exec` — see the stdin comment. An earlier version of THIS line had
|
||||
# it, eight lines below the comment forbidding it, which is why the rule is now restated at
|
||||
# every `exec` rather than once.
|
||||
# NO `2>/dev/null` ON THE `exec` — see the stdin comment. The rule is restated at every `exec`
|
||||
# rather than once because it has been violated eight lines below a single statement of it.
|
||||
if [ -r "${ETV_HOOK_FIRE_STDOUT_TMP:-}" ]; then
|
||||
# Common path: stream the FILE straight through. A shell variable cannot hold a NUL byte, so
|
||||
# replaying via `$(...)` silently drops them and warns on stderr; `cat` is byte-exact for any
|
||||
@@ -452,9 +453,16 @@ etv_hook_fire_end() {
|
||||
# Read side: the report
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
|
||||
# The hook population, DERIVED from the filesystem, never listed
|
||||
# (`testing.guard-derives-population-from-source`). A hook added tomorrow appears in the report as
|
||||
# The hook population, DERIVED rather than listed. A hook added tomorrow appears in the report as
|
||||
# NEVER-FIRED the moment it exists, rather than being invisible because nobody updated an array.
|
||||
#
|
||||
# A FILESYSTEM WALK, and that is the carve-out rather than the rule.
|
||||
# `testing.guard-derives-population-from-source` requires the GIT INDEX for a guard's file
|
||||
# population and allows a walk only with its reason written down, so: this is the REPORT, not a
|
||||
# gate — it describes what is on disk in the tree it was run from, and an untracked scratch hook
|
||||
# that really does fire is a row a reader wants to see. The guard over these same hooks
|
||||
# (`test_hook_fire_log.py`) uses `git ls-files`, and the two populations are deliberately not the
|
||||
# same one.
|
||||
etv_hook_fire_population() {
|
||||
local root="${1:-}" f
|
||||
[ -d "$root/.claude/hooks" ] || return 0
|
||||
@@ -464,12 +472,41 @@ etv_hook_fire_population() {
|
||||
done
|
||||
}
|
||||
|
||||
# SELF-LOCATED, for the same reason the write side is (ersatztv#891,
|
||||
# `process.hook-resolves-inputs-from-repo-root`). This used to prefer `$CLAUDE_PROJECT_DIR` when it
|
||||
# named a tree with a `.claude/hooks`, which read as harmless because the report decides nothing.
|
||||
# It is not: the report's two halves are the POPULATION (from this root) and the LOG (from
|
||||
# `$ETV_HOOK_FIRE_LOG_DIR`). Resolving them from two different roots is how a report earns a
|
||||
# NEVER-FIRED row for a hook that fired, or omits a hook that exists — a confident wrong answer
|
||||
# about another checkout, in a table whose entire purpose is to replace inference with measurement.
|
||||
# The env var bought nothing the fallback did not: self-location is what already makes
|
||||
# `scripts/hook-fire-log.sh report` work from any directory.
|
||||
#
|
||||
# A WRONG ROOT CANNOT BECOME A PLAUSIBLE REPORT, and that is enforced below rather than hoped for:
|
||||
# the root must own this file. Without that, self-location alone still resolves somewhere, and a
|
||||
# root that happens to hold a `.claude/hooks` would be reported on confidently. (`$PATH` invocation
|
||||
# is NOT such a case, measured 2026-08-30: bash sets `${BASH_SOURCE[0]}` to the resolved path, so
|
||||
# `PATH=…/scripts hook-fire-log.sh report` self-locates correctly.)
|
||||
etv_hook_fire_repo_root() {
|
||||
if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -d "$CLAUDE_PROJECT_DIR/.claude/hooks" ]; then
|
||||
printf '%s' "$CLAUDE_PROJECT_DIR"
|
||||
return 0
|
||||
fi
|
||||
( cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." >/dev/null 2>&1 && pwd )
|
||||
local self root
|
||||
self="${BASH_SOURCE[0]}"
|
||||
root="$( cd -- "$(dirname -- "$self")/.." >/dev/null 2>&1 && pwd )" || return 0
|
||||
# AND THE ROOT MUST OWN THIS FILE. Self-location alone answers "two directories up from wherever
|
||||
# I am", which is this repo only when this file sits in it. A copy or symlink whose grandparent
|
||||
# happens to hold a `.claude/hooks` otherwise roots there and prints a confident table about
|
||||
# another tree's hooks. The shape is ordinary — `$HOME` commonly holds a `.claude/hooks` (it does
|
||||
# on this machine), so anything dropped in a `$HOME/<dir>/` roots at `$HOME` — and it is the
|
||||
# SILENT direction, since the wrong answer is a plausible one. Requiring the root's own sink to BE
|
||||
# this file makes that case return nothing, which `etv_hook_fire_report` refuses with exit 2.
|
||||
# `-ef` AND NOT `-e`, because identity is strictly stronger than presence. A mere `-e` already
|
||||
# rejects the common shape above — `$HOME` holds no `scripts/hook-fire-log.sh`, so presence fails
|
||||
# and the report refuses. What only `-ef` rejects is the narrower shape where the wrong root DOES
|
||||
# hold a sink of that name: `-e` accepts it and prints a confident table about that other tree.
|
||||
# Proven by `test_the_report_REFUSES_a_root_that_does_not_OWN_this_sink`. Of its three arms only
|
||||
# `other-sink` distinguishes the two operators; the copy and symlink arms are the shapes that
|
||||
# actually occur, and a weakened `-e` still passes them — so they cannot be the proof.
|
||||
[ "$root/scripts/hook-fire-log.sh" -ef "$self" ] || return 0
|
||||
printf '%s' "$root"
|
||||
}
|
||||
|
||||
etv_hook_fire_report() {
|
||||
@@ -502,6 +539,14 @@ etv_hook_fire_report() {
|
||||
|
||||
# ANTI-VACUITY. A report over an empty population would print a clean table of nothing and read
|
||||
# as "all hooks accounted for" — the exact failure this file exists to end.
|
||||
#
|
||||
# THE TWO REASONS ARE REPORTED SEPARATELY. An empty `$root` means the ownership check above
|
||||
# declined to name a checkout, not that a checkout was searched and found bare; saying "no hooks
|
||||
# under /.claude/hooks" for that case is a refusal stating a finding it never made.
|
||||
if [ -z "$root" ]; then
|
||||
printf 'hook-fire-log: could not establish which checkout this sink belongs to (%s is not the `scripts/hook-fire-log.sh` of the tree two levels above it) — refusing to report rather than describing some other tree.\n' "${BASH_SOURCE[0]}" >&2
|
||||
return 2
|
||||
fi
|
||||
local pop
|
||||
pop=$(etv_hook_fire_population "$root")
|
||||
if [ -z "$pop" ]; then
|
||||
|
||||
@@ -109,18 +109,18 @@ fi
|
||||
# warning line or a date prefix as the version — `2026.07.26 jq-1.6` parsed as 2026.07, which sails
|
||||
# over the floor. Anchoring keeps every legitimate form (`jq-1.6`, `jq version 1.6`, `jq-1.7.1`,
|
||||
# `jq-1.6-dirty`, `jq-1.6 (Debian 1.6-2.1)`) and rejects the rest, which then fails closed below.
|
||||
# FIRST LINE ONLY, and bounded everywhere. Both bounds are load-bearing; this is the third round on
|
||||
# this one predicate and each previous version failed for a variant of the same reason.
|
||||
# FIRST LINE ONLY, and bounded everywhere. Both bounds are load-bearing; this is the third narrowing of
|
||||
# this one predicate and each earlier narrowing failed for a variant of the same reason.
|
||||
#
|
||||
# * First line only. `[[:space:]]` matches NEWLINES, so an "anchored" pattern still scanned the
|
||||
# whole output: `jq\n2.34: cannot load` matched `jq`, crossed the newline as separator, and
|
||||
# parsed 2.34 — fail-open, the round-2 bug narrowed but not closed. `[[:blank:]]` (space/tab
|
||||
# parsed 2.34 — the fail-open narrowed but not closed. `[[:blank:]]` (space/tab
|
||||
# only) plus a first-line slice confines the match to the line that can actually carry a version.
|
||||
# * Bounded digit runs. This is the round-1 mechanism resurrected. The regex guaranteed the
|
||||
# * Bounded digit runs. This is the original fail-open mechanism resurrected. The regex guaranteed the
|
||||
# operands were digits but not that they fit in `test`'s integer range, so a 23-digit major made
|
||||
# `[ "$major" -lt "$min_major" ]` error with "integer expression expected" — and `set -e` exempts
|
||||
# a failing command in an `if` condition, so the conditional read false and THE FLOOR WAS NEVER
|
||||
# ASSERTED, exit 0. Exactly what the empty-string case did in round 1. `{1,9}` keeps every
|
||||
# ASSERTED, exit 0. Exactly what the empty-string case did before the bound. `{1,9}` keeps every
|
||||
# operand inside a 32-bit integer, so the comparison can no longer error.
|
||||
# * Bounded separator runs, so the pattern cannot be walked across arbitrary filler.
|
||||
first=${raw%%$'\n'*}
|
||||
|
||||
@@ -75,15 +75,15 @@
|
||||
# order is right — precedent for a style is not evidence the behaviour is correct, and this chain
|
||||
# was wrong about everything else on this line. If a server ever sends two different non-empty
|
||||
# names, decide then, with the payload in hand.
|
||||
# THREE outcomes per field, not two, and collapsing the last two is a regression cold review
|
||||
# caught in the first draft of this fix. A field either supplies a NAME, or SKIPs (it is simply
|
||||
# THREE outcomes per field, not two, and collapsing the last two is a REGRESSION.
|
||||
# A field either supplies a NAME, or SKIPs (it is simply
|
||||
# not carrying one: absent, null, or the empty string Gitea sends for a glob rule), or POISONs
|
||||
# (it is PRESENT holding a type a name cannot have).
|
||||
#
|
||||
# The first draft fell through to `rule_name` on anything that was not a usable string, which
|
||||
# made a malformed field indistinguishable from an unsupplied one. Measured: with `branch_name`
|
||||
# Falling through to `rule_name` on anything that was not a usable string
|
||||
# makes a malformed field indistinguishable from an unsupplied one. Measured: with `branch_name`
|
||||
# a number, boolean or array beside `rule_name:"main"`, the predecessor THREW and the gate asked,
|
||||
# while that draft answered `exact` and AUTO-GRANTED — and with `rule_name:"develop"` it answered
|
||||
# while that fallback answered `exact` and AUTO-GRANTED — and with `rule_name:"develop"` it answered
|
||||
# `none`, denying on "the full rule list was read and none matches" about a field it never read.
|
||||
# Both directions of the very defect this file is fixing, reintroduced one shape over.
|
||||
#
|
||||
@@ -94,8 +94,8 @@
|
||||
elif $v == null then {skip: true}
|
||||
else {poison: true} end;
|
||||
# POISON IS CHECKED ON BOTH FIELDS BEFORE EITHER NAME IS TAKEN, so the invariant above holds
|
||||
# field-agnostically. The first draft short-circuited on `branch_name` and only consulted
|
||||
# `rule_name` as a fallback, which made the rule order-dependent: `{"branch_name":42,
|
||||
# field-agnostically. Short-circuiting on `branch_name` and only consulting
|
||||
# `rule_name` as a fallback makes the rule order-dependent: `{"branch_name":42,
|
||||
# "rule_name":"main"}` poisoned, while `{"branch_name":"main","rule_name":42}` answered `exact`
|
||||
# off the good field and never looked at the malformed one. Gitea 1.27.1 does not send that
|
||||
# second shape — a plain rule sets both fields equal, a glob rule sets `branch_name` to `""` —
|
||||
|
||||
@@ -98,8 +98,8 @@ unset _etv_h10_root
|
||||
# Reset to 0 HERE, at load time, so that "derived successfully" is a DATA dependency the query
|
||||
# function requires rather than a control-flow step a caller is trusted to have taken.
|
||||
#
|
||||
# ATTRIBUTED CAREFULLY, because the obvious second reason is not this line's. An earlier version
|
||||
# claimed the reset defeats an inherited `ETV_H10_REVIEWERS_OK=1` from the environment, and cited a
|
||||
# ATTRIBUTED CAREFULLY, because the obvious second reason is not this line's. That reason is that
|
||||
# the reset defeats an inherited `ETV_H10_REVIEWERS_OK=1` from the environment, backed by a
|
||||
# measurement showing a hostile environment does not get a verdict past the writer. The measurement
|
||||
# is real; the conclusion does not follow. Removing this reset produces a byte-identical refusal,
|
||||
# because `etv_h10_reviewers_load` runs on that path and overwrites both variables anyway. What the
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user