Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f7b763a7b | ||
|
|
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 | ||
|
|
4c44a13e3d | ||
|
|
1506e5a545 | ||
|
|
58def5faa1 | ||
|
|
69b30d15ec | ||
|
|
8141ac7807 | ||
|
|
06b8081664 | ||
|
|
3ec73f3769 | ||
|
|
848216e277 | ||
|
|
9b99a23835 | ||
|
|
3a88f5dc3d | ||
|
|
566cabea6d | ||
|
|
b53c3bab47 | ||
|
|
376aad6774 | ||
|
|
090a29db3d | ||
|
|
a4df8f7958 | ||
|
|
febaad77d7 | ||
|
|
cf5f42edf9 | ||
|
|
0e40ac283b | ||
|
|
528383cf3a | ||
|
|
58681b3a79 | ||
|
|
d4b36ac232 | ||
|
|
c2c70e50ad | ||
|
|
879208d554 | ||
|
|
691a14d039 | ||
|
|
72cca9a280 | ||
|
|
6fdf48de2d | ||
|
|
bbaf1d76f8 | ||
|
|
28e82fcb59 | ||
|
|
168fe21088 | ||
|
|
957a328f33 | ||
|
|
e30702111f | ||
|
|
5d955000f3 | ||
|
|
1d50241833 | ||
|
|
94a3d13495 | ||
|
|
e8f80c42ce |
@@ -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,9 +45,21 @@
|
||||
# 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:-...}` — 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.
|
||||
#
|
||||
# That line is `. `-SOURCED, so whatever it names runs AS CODE inside this hook, before stdin is read
|
||||
# and before `decide` exists. It is therefore not "telemetry" in any sense a gate can rely on.
|
||||
# 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.
|
||||
|
||||
# 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-merge-consent "" capture || true
|
||||
@@ -174,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).
|
||||
#
|
||||
@@ -195,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")
|
||||
@@ -321,6 +333,50 @@ for n in $issues; do
|
||||
fi
|
||||
done
|
||||
|
||||
# ONE branch-protection READ per run (ersatztv#859). Two arms consume this endpoint — the scheduled
|
||||
# path's `review-verdict/h10` required-check test, and the guard-scope freshness check at the bottom
|
||||
# — and they used to issue independent GETs, so a scheduled auto-merge hit it twice (measured: the
|
||||
# test stub recorded 2 URLs).
|
||||
#
|
||||
# THE ROUND TRIP IS THE SMALLER HALF. What matters is that branch protection is MUTABLE config: two
|
||||
# reads can return two different answers, and the gap between them is a gap in which the two arms
|
||||
# decide about different repo states — one concluding `review-verdict/h10` is required on the base
|
||||
# while the other classifies a rule list that no longer says so. Neither arm can detect that; both
|
||||
# would report confidently. Caching makes a single run internally consistent BY CONSTRUCTION, which
|
||||
# is a property no retry or ordering change can supply.
|
||||
#
|
||||
# WHY #787 DID NOT ALREADY SHARE IT, since the obvious question is why two reads existed at all: the
|
||||
# arms ask genuinely different QUESTIONS — one about `$base_ref` and its required contexts, one about
|
||||
# `main` and snapshot freshness — so their classifications must stay separate. But they ask those
|
||||
# questions of the same URL with the same credentials, so the RESPONSE is shareable even though the
|
||||
# verdicts are not. Cache the bytes; never cache a verdict.
|
||||
#
|
||||
# This does NOT pin anything: protection can still change after the read, and the honest ceiling is
|
||||
# unchanged (`process.check-and-use-pins-a-version`). It removes a second window, it does not remove
|
||||
# the first.
|
||||
bp_fetched=no
|
||||
bp_cache=""
|
||||
bp_cache_code=""
|
||||
fetch_branch_protections() {
|
||||
# Idempotent by design: every caller invokes it unconditionally and the FIRST one pays. A caller
|
||||
# that had to know whether it was first would be a second place for the two arms to disagree.
|
||||
if [ "$bp_fetched" = yes ]; then return 0; fi
|
||||
bp_fetched=yes
|
||||
local f
|
||||
# A temp-file failure gets its own sentinel rather than an HTTP-shaped one, so each caller can
|
||||
# keep the distinct message it had before this was shared. Reporting a mktemp failure as HTTP
|
||||
# '000 — Gitea unreachable' would state a cause that did not happen, which is the defect class
|
||||
# this whole file is organised around.
|
||||
f=$(mktemp) || { bp_cache=""; bp_cache_code=mktemp-failed; return 0; }
|
||||
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
|
||||
bp_cache_code=$(curl -s -o "$f" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
|
||||
else
|
||||
bp_cache_code=$(curl -s -o "$f" -w '%{http_code}' -u "$ETV_GITEA_BASICAUTH" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
|
||||
fi
|
||||
bp_cache=$(cat "$f" 2>/dev/null || true)
|
||||
rm -f "$f"
|
||||
}
|
||||
|
||||
# --- (a) CI combined status must be green (unless deferring to Gitea's own check-gate). ---
|
||||
if [ "$mwcs" != "true" ]; then
|
||||
[ -n "$sha" ] || decide ask "H6 merge gate: could not resolve PR #$pr head sha to check CI. Verify CI is green before merging."
|
||||
@@ -445,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
|
||||
@@ -453,13 +509,12 @@ else
|
||||
# the twin rather than documenting it is the point: one fetch, one classifier, one argument, and
|
||||
# no second path to keep in step. The ref no longer reaches a URL segment, so it needs no
|
||||
# encoding either.
|
||||
bp_file=$(mktemp) || decide ask "H6/H10 merge gate: could not allocate a temp file to read branch protection for '$base_ref'. Confirm the 'review-verdict/h10' required check manually before scheduling an auto-merge."
|
||||
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
|
||||
bp_code=$(curl -s -o "$bp_file" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
|
||||
else
|
||||
bp_code=$(curl -s -o "$bp_file" -w '%{http_code}' -u "$ETV_GITEA_BASICAUTH" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
|
||||
fetch_branch_protections
|
||||
if [ "$bp_cache_code" = "mktemp-failed" ]; then
|
||||
decide ask "H6/H10 merge gate: could not allocate a temp file to read branch protection for '$base_ref'. Confirm the 'review-verdict/h10' required check manually before scheduling an auto-merge."
|
||||
fi
|
||||
bp_list=$(cat "$bp_file" 2>/dev/null || true)
|
||||
bp_code=$bp_cache_code
|
||||
bp_list=$bp_cache
|
||||
bp=""
|
||||
if [ "$bp_code" = "200" ] && printf '%s' "$bp_list" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
||||
# DO NOT claim parity with Gitea's matcher — this code cannot have it, and asserting it would
|
||||
@@ -522,15 +577,26 @@ else
|
||||
# the two comments beside that arm were written to fix, so it is checked here rather than inherited.
|
||||
classifier="$repo_root/scripts/lib/branch-rule-classifier.jq"
|
||||
if [ ! -r "$classifier" ]; then
|
||||
rm -f "$bp_file"
|
||||
decide ask "H6/H10 merge gate: the shared branch-protection rule classifier is missing or unreadable at $classifier, so which rule governs '$base_ref' — and therefore whether 'review-verdict/h10' is required on it — could not be derived (ersatztv#787). Restore the file, or confirm the required checks manually."
|
||||
fi
|
||||
bp_verdict=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c -f "$classifier" 2>/dev/null || true)
|
||||
case $(printf '%s' "$bp_verdict" | jq -r '.verdict // ""' 2>/dev/null || true) in
|
||||
exact) bp=$(printf '%s' "$bp_verdict" | jq -c '.rule' 2>/dev/null || true); bp_code=200 ;;
|
||||
undecidable) rm -f "$bp_file"
|
||||
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." ;;
|
||||
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. 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.)
|
||||
#
|
||||
# They are different findings and now say so. `unnamed-rule` means the list was READ and a rule
|
||||
# in it carries no usable name; `unreadable-rules` means jq died or answered a word this hook
|
||||
# does not know. Same decision (ask), different cause — and naming the cause accurately is the
|
||||
# entire subject of this issue, so collapsing them here would have reproduced the defect being
|
||||
# fixed, one arm over.
|
||||
unreadable) bp_code=unnamed-rule; bp="" ;;
|
||||
*) bp_code=unreadable-rules; bp="" ;;
|
||||
esac
|
||||
else
|
||||
@@ -546,7 +612,6 @@ else
|
||||
fi
|
||||
bp=""
|
||||
fi
|
||||
rm -f "$bp_file"
|
||||
# `nomatch` is the CLASSIFIER's verdict, deliberately not an HTTP code. Reusing 404 for it made
|
||||
# this deny reachable from an HTTP 404 on the list read too — repo not found, or invisible to the
|
||||
# credential, which Gitea also answers 404 — and then the reason claimed "the full rule list was
|
||||
@@ -555,11 +620,26 @@ else
|
||||
if [ "$bp_code" = "nomatch" ]; then
|
||||
decide deny "H6/H10 merge gate: BLOCKED — no branch-protection rule on this repo can govern '$base_ref' (the full rule list was read and none matches), so 'review-verdict/h10' is not a required check on it. A scheduled auto-merge is safe ONLY because that per-sha required check stops a commit pushed after scheduling from merging unreviewed (ersatztv#622). Restore branch protection on '$base_ref', or merge immediately (without merge_when_checks_succeed) once CI is green."
|
||||
fi
|
||||
# `unreadable-rules` is the CLASSIFIER failing on a 200 it could not parse — a numeric
|
||||
# `branch_name` makes jq throw, and `//` does not catch it because it fires only on null/false.
|
||||
# It gets its own sentinel for the same reason `nomatch` does: reporting "HTTP '000' — Gitea
|
||||
# unreachable" about a successful 200 read states a cause that did not happen, which is the defect
|
||||
# fixed one arm over for the deny.
|
||||
# `unnamed-rule` is the classifier reporting a rule whose NAME it could not use. Two distinct
|
||||
# shapes, and the reason string must cover both or it states a cause that did not happen: EITHER
|
||||
# both fields supply no name (absent, null, or empty), OR one of them is present holding a
|
||||
# non-string, which poisons the rule however good its sibling is. It is deliberately NOT reported as
|
||||
# "no rule matches": a rule that cannot be read might be the rule Gitea is applying, so a list
|
||||
# containing one supports no finding about which rule governs the base. That was the #859 defect —
|
||||
# `""` is a valid name that matches nothing, so an unreadable rule DENIED with a stated cause that
|
||||
# had not happened.
|
||||
if [ "$bp_code" = "unnamed-rule" ]; then
|
||||
decide ask "H6/H10 merge gate: a branch-protection rule on this repo carries no name this hook can use — either both 'branch_name' and 'rule_name' are absent/null/empty, or one of them is present holding something that is not a string. Which rule governs '$base_ref', and whether 'review-verdict/h10' is required on it, therefore could not be derived. A rule that cannot be read might be the one Gitea applies, so this is deliberately NOT reported as 'no rule matches' (ersatztv#859). Inspect the branch-protection rules, or merge immediately instead of scheduling."
|
||||
fi
|
||||
# `unreadable-rules` is the CLASSIFIER failing on a 200 this hook could not turn into a verdict —
|
||||
# jq died, or answered a word this contract does not define. It gets its own sentinel for the same
|
||||
# reason `nomatch` does: reporting "HTTP '000' — Gitea unreachable" about a successful 200 read
|
||||
# states a cause that did not happen, which is the defect fixed one arm over for the deny.
|
||||
#
|
||||
# A numeric `branch_name` was the worked example here until ersatztv#859 and no longer reaches this
|
||||
# arm: it is not a usable NAME, so the classifier now classifies it rather than throwing on it, and
|
||||
# it lands on `unnamed-rule` above with the cause that actually applies. The example is corrected
|
||||
# rather than dropped, because it is the one shape a reader is likely to reach for when testing.
|
||||
if [ "$bp_code" = "unreadable-rules" ]; then
|
||||
decide ask "H6/H10 merge gate: this repo's branch-protection rules came back in a shape this hook could not parse, so whether 'review-verdict/h10' is required on '$base_ref' is unknown. Check the rules manually, or merge immediately instead of scheduling."
|
||||
fi
|
||||
@@ -620,7 +700,19 @@ fi
|
||||
# inside a fenced code block (documentation showing the convention counted as a real verdict), and a
|
||||
# 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.
|
||||
verdict_script="${CLAUDE_PROJECT_DIR:-.}/scripts/check-review-verdict.sh"
|
||||
# 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, #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.
|
||||
#
|
||||
# Site-specific consequence only: a `$CLAUDE_PROJECT_DIR` naming a sibling worktree — routine here —
|
||||
# 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. 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."
|
||||
fi
|
||||
@@ -678,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
|
||||
@@ -691,14 +783,12 @@ esac
|
||||
# stale is a guard's scope — a different artifact, on a different clock. Denying would state
|
||||
# something false about the change in front of the reader. Every non-`match` class asks, so a
|
||||
# comparison that could not be made is surfaced rather than skipped (`unknown` is not `fine`).
|
||||
# ONE base for both the checker and the snapshot, and it is `$repo_root` — derived from this file's
|
||||
# own location — rather than `$CLAUDE_PROJECT_DIR`. Two reasons, and the second is the load-bearing
|
||||
# one. Resolving them from different roots would let the hook classify one checkout's snapshot with
|
||||
# another checkout's script, mismatched halves of a comparison whose whole job is to detect a
|
||||
# mismatch. And an ENV VAR is not a sound input to a security decision: a wrong value pointing at a
|
||||
# tree that happens to contain an executable checker returns `match` about a different checkout
|
||||
# entirely, which silently authorizes the grant. A missing path only asks, so the failure is quiet
|
||||
# exactly where it is worst.
|
||||
# ONE base for both the checker and the snapshot, and it is `$repo_root` — see
|
||||
# `process.hook-resolves-inputs-from-repo-root` for why an env var may not select either
|
||||
# (ersatztv#787, #858). The reason specific to THIS arm is that both halves of a comparison are
|
||||
# resolved here: from two different roots the hook would classify one checkout's snapshot with
|
||||
# another checkout's script — mismatched halves of a comparison whose entire job is to detect a
|
||||
# mismatch — and answer `match` about a tree nobody asked about.
|
||||
ctx_base="$repo_root"
|
||||
ctx_snapshot="$ctx_base/.gitea/required-status-contexts.json"
|
||||
ctx_script="$ctx_base/scripts/check-required-contexts.sh"
|
||||
@@ -725,24 +815,33 @@ if [ "$ctx_repo_fold" = "$target_repo_fold" ]; then
|
||||
if [ ! -x "$ctx_script" ]; then
|
||||
decide ask "H6 merge gate: the required-contexts checker is missing or not executable at $ctx_script, so whether the dropped-step guard's scope still matches branch protection on 'main' could not be derived (ersatztv#787). Check it manually, or restore the script."
|
||||
fi
|
||||
bpf=$(mktemp) || decide ask "H6 merge gate: could not allocate a temp file to read branch protection for the guard-scope freshness check (ersatztv#787)."
|
||||
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
|
||||
ctx_code=$(curl -s -o "$bpf" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
|
||||
else
|
||||
ctx_code=$(curl -s -o "$bpf" -w '%{http_code}' -u "$ETV_GITEA_BASICAUTH" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
|
||||
# THE SHARED READ (ersatztv#859). On a scheduled merge the arm above already fetched this; here that
|
||||
# call is a cache hit, so the endpoint is read once per run instead of twice. On the IMMEDIATE path
|
||||
# this is the only consumer and it performs the fetch itself, which is why the call sits AFTER the
|
||||
# `[ ! -x "$ctx_script" ]` check above: a missing checker must ask without having touched the
|
||||
# network, and a test pins exactly that by asserting no branch-protection URL was recorded.
|
||||
fetch_branch_protections
|
||||
if [ "$bp_cache_code" = "mktemp-failed" ]; then
|
||||
decide ask "H6 merge gate: could not allocate a temp file to read branch protection for the guard-scope freshness check (ersatztv#787)."
|
||||
fi
|
||||
ctx_code=$bp_cache_code
|
||||
# ONE temp file, and it holds the checker's STDERR. Until ersatztv#859 this was `mktemp` for the
|
||||
# payload plus an unmanaged `$bpf.err` beside it — a second path mktemp never created and therefore
|
||||
# never made unpredictable. The payload now comes from the shared cache over a pipe, so the only
|
||||
# thing still needing a file is the diagnostic, and it gets the mktemp'd one.
|
||||
ctx_err=$(mktemp) || decide ask "H6 merge gate: could not allocate a temp file for the guard-scope freshness check's diagnostics (ersatztv#787)."
|
||||
if [ "$ctx_code" = "200" ]; then
|
||||
# stderr is KEPT, not sent to /dev/null. The checker exits 2 with a diagnostic on a usage error —
|
||||
# an unreadable snapshot, a branch mismatch, a missing classifier — and discarding it made all of
|
||||
# those arrive at the operator as the catch-all's "returned 'nothing'", which names no cause. That
|
||||
# is the same states-a-cause-that-did-not-happen shape this arm was careful about elsewhere.
|
||||
ctx_class=$("$ctx_script" --branch main --snapshot "$ctx_snapshot" < "$bpf" 2>"$bpf.err" || true)
|
||||
ctx_diag=$(tr '\n' ' ' < "$bpf.err" 2>/dev/null | cut -c1-300 || true)
|
||||
ctx_class=$(printf '%s' "$bp_cache" | "$ctx_script" --branch main --snapshot "$ctx_snapshot" 2>"$ctx_err" || true)
|
||||
ctx_diag=$(tr '\n' ' ' < "$ctx_err" 2>/dev/null | cut -c1-300 || true)
|
||||
else
|
||||
ctx_class=readfail
|
||||
ctx_diag=""
|
||||
fi
|
||||
rm -f "$bpf" "$bpf.err"
|
||||
rm -f "$ctx_err"
|
||||
case "$ctx_class" in
|
||||
match) : ;;
|
||||
drift)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -176,7 +176,7 @@ docker compose -f $D/compose.yaml up -d --no-deps ersatztv-test
|
||||
|
||||
### Mutation Endpoints (POST)
|
||||
```bash
|
||||
# Library scan
|
||||
# Library scan (?deep=true also rescans items a media server reports unchanged)
|
||||
POST /api/v1/libraries/{id}/scan
|
||||
|
||||
# Scan single show
|
||||
@@ -472,6 +472,11 @@ docker start ersatztv
|
||||
### Common Mistakes (check every time)
|
||||
- **Playout not building**: Three things must all be correct: (1) `ProgramScheduleOneItem` row exists for the schedule item, (2) `PlaybackOrder=3` (Shuffle), (3) `ScheduleKind=1` on Playout. Missing any one results in 0 playout items — this is the most common issue.
|
||||
- **Collection queries fail**: `CollectionItem` has no `Id` column — use `rowid` for counting. Content lookup goes through `MediaVersion.MovieId` → `MediaFile.Path` (not a generic MediaItemId join).
|
||||
- **Channel won't start — "has no video stream on record"** (images before v26.16.0, which carries the ersatztv#924 fix, log a bare
|
||||
`Sequence contains no matching element` at `FFmpegStreamSelector.SelectVideoStream`): the playing item's
|
||||
`MediaVersion` has no `MediaStream` rows. Deep-scan its library — `POST /api/v1/libraries/{id}/scan?deep=true`;
|
||||
a regular scan skips items whose server etag is unchanged and cannot heal them. If the file itself has no video
|
||||
stream, no scan heals it. Jellyfin music videos scanned before that fix were all stored without streams (`scan.mediaserver-statistics-from-server`).
|
||||
- **Channel logos forgotten**: After creating a channel, add an Artwork row (ArtworkKind=2) + logo file, then run `fix_logos.py` to push to Jellyfin. Without this, the channel shows no logo in the EPG.
|
||||
- **Playout reset required**: After any schedule/collection change, run `POST /api/v1/channels/{id}/playout/reset`. Wait 5-10s for the playout to build before verifying item count.
|
||||
|
||||
|
||||
@@ -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 }
|
||||
@@ -36,9 +36,15 @@ on:
|
||||
# from the pushed ref like everything else in this file, so a branch that DELETES it re-enables
|
||||
# the route. What closes is the DRIVE-BY case — an ordinary push of a legitimate `docker/ci`
|
||||
# change publishing an image nobody asked for, with no deliberate act anywhere. This is NOT a
|
||||
# boundary against a malicious or compromised writer and must not be cited as one; that class is
|
||||
# ersatztv#853. `workflow_dispatch` is self-supplied from the selected branch in the same way, and
|
||||
# is the deliberate publish path (docs/ci-cd.md -> "CI toolchain image").
|
||||
# boundary against a malicious or compromised writer and must not be cited as one. That class was
|
||||
# probed and ACCEPTED in ersatztv#853 (`ci.workflow-dispatch-ref-unrestricted`): Gitea 1.27.1 cannot
|
||||
# restrict `workflow_dispatch` by ref, and restricting it would close nothing anyway:
|
||||
# docker-build.yml's head-resolved `pull_request:` runs attacker-authored YAML, which reaches every
|
||||
# secret in the store — so it covers renovate.yml's RENOVATE_TOKEN too, without dispatching
|
||||
# renovate.yml at all. Only the DISPATCH third is settled; the `v*` tag push and the PR route
|
||||
# itself remain open in ersatztv#885. `workflow_dispatch` is loaded from the ref it is dispatched
|
||||
# on, exactly as the `branches:` filter below is loaded from the pushed ref, and is the deliberate
|
||||
# publish path (docs/ci-cd.md -> "CI toolchain image").
|
||||
#
|
||||
# A `v*` tag push does not match this trigger either: there is no `tags:` key, and a `branches:`
|
||||
# filter is compared against a branch ref. The exact matcher semantics are not probed here; the
|
||||
@@ -54,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
|
||||
@@ -865,7 +864,27 @@ jobs:
|
||||
INFO_VERSION="${VERSION}"
|
||||
TAGS=("${IMAGE}:prod" "${IMAGE}:${VERSION}" "${IMAGE}:${SHORT}")
|
||||
else
|
||||
DESC=$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)
|
||||
# `git describe` MUST resolve here, and a failure is fatal rather than defaulted
|
||||
# (ersatztv#836). This job checks out `fetch-depth: 0`, so the tags are present; the
|
||||
# only thing that ever stopped `describe` from seeing them was the detector step above
|
||||
# grafting this complete clone shallow. The old `|| echo v0.0.0` was a fallback that
|
||||
# cannot fail, so from 2026-07-17 (when #416 introduced the depth) until #836 every
|
||||
# `:latest` image was published carrying
|
||||
# `InformationalVersion 0.0.0-<sha>` and nothing anywhere went red — the defect was
|
||||
# found by reading the string out of a running container, which is not a detector.
|
||||
# Failing the job instead means no `:latest` is published at all: visible, recoverable,
|
||||
# and never a mislabelled image promoted downstream. The tag path above never calls
|
||||
# `describe`, so a release cut is unaffected by this.
|
||||
# stderr is discarded on the CAPTURE and re-run for the diagnostic, rather than folded
|
||||
# in with `2>&1`: a git warning on the SUCCESS path would otherwise land inside DESC and
|
||||
# become part of the version string — the same shape of silent corruption this whole
|
||||
# step is being hardened against.
|
||||
if ! DESC=$(git describe --tags --abbrev=0 2>/dev/null); then
|
||||
echo "is-shallow-repository=$(git rev-parse --is-shallow-repository)"
|
||||
git describe --tags --abbrev=0 || true
|
||||
echo "::error::git describe --tags --abbrev=0 failed, so this image would ship InformationalVersion 0.0.0-${SHORT} instead of a real version (ersatztv#836). The usual cause is a --depth fetch grafting this complete clone shallow; the two lines above say which."
|
||||
exit 1
|
||||
fi
|
||||
INFO_VERSION="${DESC#v}-${SHORT}"
|
||||
TAGS=("${IMAGE}:latest" "${IMAGE}:${SHORT}")
|
||||
fi
|
||||
@@ -1024,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
|
||||
@@ -1129,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
|
||||
@@ -301,11 +306,16 @@ jobs:
|
||||
# close. A distinct job name keeps a real failure unambiguous.
|
||||
#
|
||||
# Runs UNCONDITIONALLY on every PR rather than behind a `scripts/**` path filter. The suite's
|
||||
# corpus tests are fixture/tmp-repo based, but test_post_review_verdict.py and
|
||||
# test_merge_consent_exemption.py execute the REAL `scripts/post-review-verdict.sh` and
|
||||
# `.claude/hooks/pretooluse-merge-consent.sh`, so its true input set spans at least two top-level
|
||||
# directories. A `scripts/**` filter would silently miss a `.claude/hooks/**` edit — and at ~10s a
|
||||
# filter buys nothing but drift.
|
||||
# corpus tests are fixture/tmp-repo based, but several execute REAL artifacts from other top-level
|
||||
# directories: test_post_review_verdict.py runs `scripts/post-review-verdict.sh`,
|
||||
# test_merge_consent_exemption.py runs `.claude/hooks/pretooluse-merge-consent.sh`, and since
|
||||
# ersatztv#845 test_post_review_verdict.py ALSO reads `.gitea/workflows/review-verdict.yml` —
|
||||
# the writer derives the H10 allow-list from it, so editing that literal changes the suite's
|
||||
# outcome. Its true input set therefore spans at least three top-level directories, and this
|
||||
# enumeration is the kind that goes stale: a `scripts/**` filter would silently miss a
|
||||
# `.claude/hooks/**` or `.gitea/workflows/**` edit. The reason is the INPUT SET, not the cost —
|
||||
# the suite was ~10s when that was decided and is minutes now, and filtering on `scripts/**`
|
||||
# would still be wrong.
|
||||
prove-fix:
|
||||
name: "Fix proofs (Proves trailers)"
|
||||
runs-on: small
|
||||
@@ -530,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
|
||||
|
||||
+1127
-208
File diff suppressed because it is too large
Load Diff
@@ -83,7 +83,7 @@ main in) and re-run the local gate whenever the fetch shows movement.
|
||||
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use `/done <issue>` to run through this automatically.
|
||||
|
||||
**Merge-consent is derived from state, not asserted (`## Done-when` convention — ersatztv#303 H6 + H10).** Any issue whose PR will merge to `main` should carry a `## Done-when` section in its **issue body** — a checklist of completion criteria (always include an "adversarial review passed" box; add per-issue criteria like tests-green, docs-updated, live-E2E). Two hooks derive merge-consent from it so a premature merge is blocked *by construction*, not by memory:
|
||||
- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **auto-grants** a merge (emits `permissionDecision: allow`, so **no** redundant mechanical prompt fires) only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked **and** a `Review-verdict:` comment references the PR's *current head sha* (**H10**); **denies** on an unticked box, red CI, or a stale/negative review verdict; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no `Review-verdict:` comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state **is** the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate **asks** (ersatztv#314). **The H10 review-verdict convention**: after an adversarial/Codex review of a PR (or its latest fix commit), run **`scripts/post-review-verdict.sh <pr> <MERGEABLE|APPROVED|LGTM|BLOCKED|NOT-MERGEABLE> [note]`** — it posts both the `Review-verdict: … @ <head-sha>` comment and the sha-bound `review-verdict/h10` commit status, proving the *latest* commit was reviewed rather than a stale earlier diff (ersatztv#242). Do not hand-write the comment: the **status** is the required check branch protection enforces, and a comment alone leaves it absent. **The credential you post with must be an account on `H10_REVIEWERS` in `.gitea/workflows/review-verdict.yml`** (`timothy` today) — since ersatztv#742 the gate inherits an existing `success` only from an allow-listed creator (an existing `failure` is left alone on a weaker attributability test, so an attributable rejection VISIBLE AT THE FIRST READ is not re-derived into a green — a rejection landing later, inside a run's own write window, is a separate and still-open route, ersatztv#849), so a verdict posted with any other account is written, reported as success by the script, and then silently re-derived on the next PR event (ersatztv#845).
|
||||
- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **auto-grants** a merge (emits `permissionDecision: allow`, so **no** redundant mechanical prompt fires) only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked **and** a `Review-verdict:` comment references the PR's *current head sha* (**H10**); **denies** on an unticked box, red CI, or a stale/negative review verdict; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no `Review-verdict:` comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state **is** the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate **asks** (ersatztv#314). **The H10 review-verdict convention**: after an adversarial/Codex review of a PR (or its latest fix commit), run **`scripts/post-review-verdict.sh <pr> <MERGEABLE|APPROVED|LGTM|BLOCKED|NOT-MERGEABLE> [note]`** — it posts both the `Review-verdict: … @ <head-sha>` comment and the sha-bound `review-verdict/h10` commit status, proving the *latest* commit was reviewed rather than a stale earlier diff (ersatztv#242). Do not hand-write the comment: the **status** is the required check branch protection enforces, and a comment alone leaves it absent. **The credential you post with must be an account on `H10_REVIEWERS` in `.gitea/workflows/review-verdict.yml`** (`timothy` today) — since ersatztv#742 the gate inherits an existing `success` only from an allow-listed creator (an existing `failure` is left alone on a weaker attributability test, so an attributable rejection VISIBLE AT THE FIRST READ is not re-derived into a green — a rejection landing later, inside a run's own write window, was a separate route and is NARROWED since ersatztv#849 — every path that cannot establish what the head carries now replaces that unknown state with a sticky sentinel instead of leaving it standing; see `ci.verdict-unverified-write-sentinel` for the residuals it names), and since ersatztv#845 the script ENFORCES that coupling rather than assuming it: it reads its own status back and refuses, before writing the verdict comment, unless the recorded `.creator.login` is on that allow-list — so a POSITIVE verdict posted with any other account fails loudly at your terminal instead of being reported as success. The gate still re-derives such a status on the next PR event — that part is unchanged; what the check removes is the tool telling you it worked. **The membership requirement is `success`-only**, mirroring the gate: a `BLOCKED` verdict is honoured from ANY attributable account, so an off-list reviewer can still record a rejection. **The status is still written** — the check runs after the POST, because it measures the creator Gitea recorded rather than what the credential claims — and what is withheld is the verdict COMMENT, which leaves the merge hook at condition (c) with nothing to classify, i.e. an `ask`. So a refused positive verdict leaves a green `review-verdict/h10` standing on that head that the gate itself will not inherit; branch protection binds the context NAME and not its issuer, so do not read that green as consent. The allow-list is derived from the workflow by `scripts/lib/h10-reviewers.sh`; it is never restated.
|
||||
- **The gate is enforced server-side, per sha (ersatztv#622).** `review-verdict/h10` is a required status check on `main`. Because a commit status belongs to one sha, a commit pushed *after* an auto-merge is scheduled clears it and blocks the merge — closing the hole where `merge_when_checks_succeed` froze consent at scheduling time and Gitea later merged an unreviewed head. Renovate-authored and docs-only PRs are auto-passed by `.gitea/workflows/review-verdict.yml`, **except** when they touch `.claude/`, `.codex/`, `.gitea/`, `.husky/`, `scripts/` or `docker/ci/`. See `docs/ci-cd.md` → Review-verdict gate.
|
||||
- `.husky/pre-push` → `prepush-donewhen.sh` — a fail-open backstop that blocks a direct `git push origin main` whose commits `fix #N` an issue with unticked boxes. **Since ersatztv#743 that push can no longer happen at all** (see below), so this hook is now belt-and-braces for a path the server refuses.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
@@ -86,6 +87,29 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
|
||||
|
||||
var incoming = request.Items.Except([highest]).ToList();
|
||||
|
||||
// Reject an EXPLICITLY empty recurrence set before any mutation (#880). The checked set is
|
||||
// `incoming` -- the exact list whose DaysOfWeek/DaysOfMonth/MonthsOfYear the loops below
|
||||
// write -- so the check and its subject cannot drift apart. That EXCLUDES the highest-Index
|
||||
// catch-all by construction: its recurrence is discarded along with its date range (only its
|
||||
// ProgramScheduleId is read, further down), so an empty set there cannot make anything "never
|
||||
// apply" and rejecting it would state a reason that is false for that item.
|
||||
foreach (ReplacePlayoutAlternateSchedule item in incoming)
|
||||
{
|
||||
ProgramScheduleAlternate stored = existing.FirstOrDefault(e => e.Id == item.Id);
|
||||
Option<BaseError> recurrenceError = RecurrenceSetBounds.Validate(
|
||||
item.DaysOfWeek,
|
||||
item.DaysOfMonth,
|
||||
item.MonthsOfYear,
|
||||
stored?.DaysOfWeek,
|
||||
stored?.DaysOfMonth,
|
||||
stored?.MonthsOfYear);
|
||||
|
||||
foreach (BaseError error in recurrenceError)
|
||||
{
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
var toAdd = incoming.Filter(x => existing.All(e => e.Id != x.Id)).ToList();
|
||||
var toRemove = existing.Filter(e => incoming.All(m => m.Id != e.Id)).ToList();
|
||||
var toUpdate = incoming.Except(toAdd).ToList();
|
||||
|
||||
@@ -44,6 +44,25 @@ public class ReplacePlayoutTemplateItemsHandler(
|
||||
|
||||
List<ReplacePlayoutTemplate> incoming = request.Items;
|
||||
|
||||
// Same rule as the alternate-schedule path (#880), over ALL items: unlike that one, every
|
||||
// template item's recurrence IS stored, so there is no catch-all to exclude here.
|
||||
foreach (ReplacePlayoutTemplate item in incoming)
|
||||
{
|
||||
PlayoutTemplate stored = existing.FirstOrDefault(e => e.Id == item.Id);
|
||||
Option<BaseError> recurrenceError = RecurrenceSetBounds.Validate(
|
||||
item.DaysOfWeek,
|
||||
item.DaysOfMonth,
|
||||
item.MonthsOfYear,
|
||||
stored?.DaysOfWeek,
|
||||
stored?.DaysOfMonth,
|
||||
stored?.MonthsOfYear);
|
||||
|
||||
if (recurrenceError.IsSome)
|
||||
{
|
||||
return recurrenceError;
|
||||
}
|
||||
}
|
||||
|
||||
var toAdd = incoming.Filter(x => existing.All(e => e.Id != x.Id)).ToList();
|
||||
var toRemove = existing.Filter(e => incoming.All(m => m.Id != e.Id)).ToList();
|
||||
var toUpdate = incoming.Except(toAdd).ToList();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the three recurrence sets shared by <c>ProgramScheduleAlternate</c> and
|
||||
/// <c>PlayoutTemplate</c> (ersatztv#880). One validator called from BOTH replace handlers, mirroring
|
||||
/// <c>FFmpegProfileBounds</c> — the exemplar for `api.ffmpeg-profile-numeric-bounds`, whose shape this
|
||||
/// follows deliberately.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// An EMPTY set is rejected because the three are read CONJUNCTIVELY by
|
||||
/// <c>AlternateScheduleSelector.GetScheduleForDate</c> — a miss on any one continues to the next
|
||||
/// item — so an empty one matches NO date and stores an item that can never apply. Rejecting
|
||||
/// rather than substituting is the point: accept-then-rewrite would make an explicit `[]`
|
||||
/// indistinguishable from an omitted field, which is the very collapse this issue removed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An UNCHANGED empty set that the row ALREADY holds is let through. Both PUT paths are
|
||||
/// whole-list replaces, so a hard rejection would make every OTHER item in the playout
|
||||
/// uneditable over a row the operator never touched — the same reason
|
||||
/// `api.ffmpeg-profile-numeric-bounds` rejects only a NEWLY submitted out-of-range value. A row
|
||||
/// whose stored set is NULL is NOT exempt: null means unrestricted, so submitting `[]` for it is
|
||||
/// a new emptying, not an unchanged legacy value.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This runs on the COMMAND, after the request records have normalized an ABSENT array to the
|
||||
/// All*() sets, so an empty set reaching here is one a caller sent EXPLICITLY. That also means a
|
||||
/// direct (non-HTTP) caller is held to the same rule rather than being able to write a dead row.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class RecurrenceSetBounds
|
||||
{
|
||||
public static Option<BaseError> Validate(
|
||||
ICollection<DayOfWeek> daysOfWeek,
|
||||
ICollection<int> daysOfMonth,
|
||||
ICollection<int> monthsOfYear,
|
||||
ICollection<DayOfWeek> storedDaysOfWeek,
|
||||
ICollection<int> storedDaysOfMonth,
|
||||
ICollection<int> storedMonthsOfYear)
|
||||
{
|
||||
if (IsNewlyEmpty(daysOfWeek, storedDaysOfWeek))
|
||||
{
|
||||
return Some(BaseError.New(Message("DaysOfWeek", "no day of the week")));
|
||||
}
|
||||
|
||||
if (IsNewlyEmpty(daysOfMonth, storedDaysOfMonth))
|
||||
{
|
||||
return Some(BaseError.New(Message("DaysOfMonth", "no day of the month")));
|
||||
}
|
||||
|
||||
if (IsNewlyEmpty(monthsOfYear, storedMonthsOfYear))
|
||||
{
|
||||
return Some(BaseError.New(Message("MonthsOfYear", "no month")));
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
|
||||
// "send null" rather than "omit the property": all three are listed in the schema's `required` array
|
||||
// in v1.json (they are nullable, not optional), so a client generated from the published contract
|
||||
// cannot omit them. Omitting also works at runtime -- Newtonsoft maps a missing property and an
|
||||
// explicit null to the same thing -- but naming only that would tell a conforming client to send
|
||||
// something its own schema forbids.
|
||||
private static string Message(string field, string consequence) =>
|
||||
$"[{field}] must not be empty; an empty set matches {consequence}, so the item would never apply. " +
|
||||
"Send null to leave it unrestricted";
|
||||
|
||||
// A new item (no stored row) has `stored` null, so an empty set is newly empty and is rejected.
|
||||
// Only a stored set that is ITSELF already empty exempts an empty submission.
|
||||
private static bool IsNewlyEmpty<T>(ICollection<T> submitted, ICollection<T> stored) =>
|
||||
submitted is { Count: 0 } && stored is not { Count: 0 };
|
||||
}
|
||||
@@ -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)";
|
||||
}
|
||||
@@ -191,4 +191,65 @@ public class FFmpegStreamSelectorTests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ersatztv#924: a version stored without a video stream (Jellyfin music videos whose statistics were never
|
||||
// read) used to surface First()'s bare "Sequence contains no matching element" from HLS session start.
|
||||
[TestFixture]
|
||||
public class SelectVideoStream
|
||||
{
|
||||
private static FFmpegStreamSelector Selector() =>
|
||||
new(
|
||||
new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()),
|
||||
Substitute.For<IStreamSelectorRepository>(),
|
||||
Substitute.For<IConfigElementRepository>(),
|
||||
new MockFileSystem(),
|
||||
Substitute.For<ILanguageCodeService>(),
|
||||
Substitute.For<ILogger<FFmpegStreamSelector>>());
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_The_Video_Stream()
|
||||
{
|
||||
var version = new MediaVersion
|
||||
{
|
||||
Id = 12,
|
||||
Streams =
|
||||
[
|
||||
new MediaStream { Index = 0, MediaStreamKind = MediaStreamKind.Audio },
|
||||
new MediaStream { Index = 1, MediaStreamKind = MediaStreamKind.Video, Codec = "h264" }
|
||||
]
|
||||
};
|
||||
|
||||
MediaStream stream = await Selector().SelectVideoStream(version);
|
||||
|
||||
stream.Index.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Name_The_Version_And_File_When_There_Is_No_Video_Stream()
|
||||
{
|
||||
var version = new MediaVersion
|
||||
{
|
||||
Id = 1520,
|
||||
MediaFiles = [new MediaFile { Path = "/data/music/artist1/song1.mkv" }],
|
||||
Streams = [new MediaStream { Index = 0, MediaStreamKind = MediaStreamKind.Audio }]
|
||||
};
|
||||
|
||||
InvalidOperationException ex =
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => Selector().SelectVideoStream(version));
|
||||
|
||||
ex.Message.ShouldContain("Media version 1520 (/data/music/artist1/song1.mkv) has no video stream");
|
||||
ex.Message.ShouldContain("deep scan");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Fail_Clearly_When_Streams_And_Files_Were_Never_Loaded()
|
||||
{
|
||||
var version = new MediaVersion { Id = 7, MediaFiles = null, Streams = null };
|
||||
|
||||
InvalidOperationException ex =
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => Selector().SelectVideoStream(version));
|
||||
|
||||
ex.Message.ShouldContain("Media version 7 ((no media file)) has no video stream");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
@@ -38,8 +38,31 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<MediaStream> SelectVideoStream(MediaVersion version) =>
|
||||
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask();
|
||||
// ersatztv#924: a version with no video stream on record (its statistics were never read, or were stored
|
||||
// empty) cannot be played. Name the version and its file and say how the statistics get populated, instead
|
||||
// of surfacing First()'s bare "Sequence contains no matching element".
|
||||
public Task<MediaStream> SelectVideoStream(MediaVersion version)
|
||||
{
|
||||
foreach (MediaStream videoStream in Optional(version.Streams).Flatten()
|
||||
.Filter(s => s.MediaStreamKind == MediaStreamKind.Video)
|
||||
.HeadOrNone())
|
||||
{
|
||||
return videoStream.AsTask();
|
||||
}
|
||||
|
||||
string path = Optional(version.MediaFiles).Flatten()
|
||||
.HeadOrNone()
|
||||
.Map(f => f.Path)
|
||||
.IfNone("(no media file)");
|
||||
|
||||
return Task.FromException<MediaStream>(
|
||||
new InvalidOperationException(
|
||||
$"Media version {version.Id} ({path}) has no video stream on record, so it cannot be played. " +
|
||||
"If its media statistics were never read, rescan the item's library to read them (for a " +
|
||||
"Jellyfin/Emby/Plex library, a deep scan, since a regular scan skips items the server reports " +
|
||||
"unchanged). If the file itself has no video stream, a rescan cannot help: remove it from " +
|
||||
"video channels."));
|
||||
}
|
||||
|
||||
public async Task<Option<MediaStream>> SelectAudioStream(
|
||||
MediaItemAudioVersion version,
|
||||
@@ -115,7 +138,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
|
||||
}
|
||||
|
||||
break;
|
||||
// let default fall through
|
||||
// let default fall through
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -19,6 +19,7 @@ public class JellyfinMusicVideoLibraryScanner :
|
||||
{
|
||||
private readonly IJellyfinApiClient _jellyfinApiClient;
|
||||
private readonly IJellyfinMusicVideoRepository _jellyfinMusicVideoRepository;
|
||||
private readonly ILogger<JellyfinMusicVideoLibraryScanner> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
private readonly IMusicVideoRepository _musicVideoRepository;
|
||||
@@ -50,6 +51,7 @@ public class JellyfinMusicVideoLibraryScanner :
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_musicVideoRepository = musicVideoRepository;
|
||||
_metadataRepository = metadataRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
@@ -97,6 +99,41 @@ public class JellyfinMusicVideoLibraryScanner :
|
||||
library,
|
||||
projectionFailures);
|
||||
|
||||
protected override async Task<Option<MediaVersion>> GetMediaServerStatistics(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
MediaItemScanResult<JellyfinMusicVideo> result,
|
||||
JellyfinMusicVideo incoming)
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Jellyfin Statistics", result.LocalPath);
|
||||
|
||||
Either<BaseError, MediaVersion> maybeVersion =
|
||||
await _jellyfinApiClient.GetPlaybackInfo(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.AuthorizationHeader,
|
||||
library,
|
||||
incoming.ItemId);
|
||||
|
||||
foreach (BaseError error in maybeVersion.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to get music video statistics from Jellyfin for {Path}: {Error}",
|
||||
result.LocalPath,
|
||||
error.ToString());
|
||||
}
|
||||
|
||||
// Chapters arrive with the library-items projection, not with playback info. They are taken from the
|
||||
// INCOMING projection, not from result.Item as JellyfinMovieLibraryScanner does: the music-video
|
||||
// repository's GetByItemId does not eager-load chapters, so on an existing row result.Item's head version
|
||||
// carries none to write.
|
||||
foreach (MediaVersion version in maybeVersion.RightToSeq())
|
||||
{
|
||||
version.Chapters = incoming.GetHeadVersion().Chapters ?? [];
|
||||
}
|
||||
|
||||
return maybeVersion.ToOption();
|
||||
}
|
||||
|
||||
protected override async Task<Either<BaseError, MediaItemScanResult<JellyfinMusicVideo>>> UpdateMetadata(
|
||||
MediaItemScanResult<JellyfinMusicVideo> result,
|
||||
JellyfinMusicVideo incoming,
|
||||
|
||||
@@ -131,6 +131,7 @@ public abstract class MediaServerMusicVideoLibraryScanner<TConnectionParameters,
|
||||
Either<BaseError, MediaItemScanResult<TMusicVideo>> maybeMusicVideo =
|
||||
await ProcessMusicVideo(
|
||||
musicVideoRepository,
|
||||
connectionParameters,
|
||||
library,
|
||||
libraryPath,
|
||||
incoming,
|
||||
@@ -250,6 +251,7 @@ public abstract class MediaServerMusicVideoLibraryScanner<TConnectionParameters,
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<TMusicVideo>>> ProcessMusicVideo(
|
||||
IMediaServerMusicVideoRepository<TLibrary, TMusicVideo, TEtag> musicVideoRepository,
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library,
|
||||
LibraryPath libraryPath,
|
||||
TMusicVideo incoming,
|
||||
@@ -279,7 +281,7 @@ public abstract class MediaServerMusicVideoLibraryScanner<TConnectionParameters,
|
||||
return result;
|
||||
})
|
||||
.BindT(result => UpdateMetadata(result, incoming, cancellationToken))
|
||||
.BindT(result => UpdateStatistics(result, incoming));
|
||||
.BindT(result => UpdateStatistics(connectionParameters, library, result, incoming, deepScan));
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Artist>> GetOrAddArtist(LibraryPath libraryPath, TMusicVideo musicVideo)
|
||||
@@ -315,13 +317,40 @@ public abstract class MediaServerMusicVideoLibraryScanner<TConnectionParameters,
|
||||
return result.Map(scanResult => scanResult.Item);
|
||||
}
|
||||
|
||||
// ersatztv#924: the library-items projection carries NO streams and no width/height (the server only reports
|
||||
// those through its per-item playback info), so the projection's version must never be written as the
|
||||
// statistics — doing so stored a version FFmpegStreamSelector cannot play, and, because
|
||||
// IMetadataRepository.UpdateStatistics reconciles streams against the version it is given, removed every
|
||||
// stream an existing row already had. Statistics come from the media server, under the same condition
|
||||
// MediaServerMovieLibraryScanner.UpdateStatistics uses; when the server cannot supply them nothing is written,
|
||||
// so stored streams are kept rather than replaced by the projection's empty list. (`result.IsAdded` is implied
|
||||
// by the empty-streams arm while the projection carries no streams; it is kept so the condition stays the
|
||||
// movie scanner's.)
|
||||
private async Task<Either<BaseError, MediaItemScanResult<TMusicVideo>>> UpdateStatistics(
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library,
|
||||
MediaItemScanResult<TMusicVideo> result,
|
||||
TMusicVideo incoming)
|
||||
TMusicVideo incoming,
|
||||
bool deepScan)
|
||||
{
|
||||
if (await _metadataRepository.UpdateStatistics(result.Item, incoming.GetHeadVersion()))
|
||||
TMusicVideo existing = result.Item;
|
||||
|
||||
if (deepScan || result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) ||
|
||||
existing.GetHeadVersion().Streams is not { Count: > 0 })
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
Option<MediaVersion> maybeMediaVersion = await GetMediaServerStatistics(
|
||||
connectionParameters,
|
||||
library,
|
||||
result,
|
||||
incoming);
|
||||
|
||||
foreach (MediaVersion mediaVersion in maybeMediaVersion)
|
||||
{
|
||||
if (await _metadataRepository.UpdateStatistics(result.Item, mediaVersion))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -405,4 +434,13 @@ public abstract class MediaServerMusicVideoLibraryScanner<TConnectionParameters,
|
||||
MediaItemScanResult<TMusicVideo> result,
|
||||
TMusicVideo incoming,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// ersatztv#924: abstract rather than the movie scanner's virtual-returning-None, because a None default is
|
||||
// exactly the defect — an implementation that forgot to override it would scan music videos with no streams.
|
||||
// The returned version carries the chapters to store alongside the statistics.
|
||||
protected abstract Task<Option<MediaVersion>> GetMediaServerStatistics(
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library,
|
||||
MediaItemScanResult<TMusicVideo> result,
|
||||
TMusicVideo incoming);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -4,6 +4,7 @@ using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -121,13 +122,200 @@ public class PlayoutHandlerTests
|
||||
LeftOf(result).Value.ShouldContain("must not be empty");
|
||||
}
|
||||
|
||||
// ---- #880 empty recurrence sets ----
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceAlternateSchedules_Should_Reject_A_Newly_Empty_Recurrence_On_A_Stored_Item()
|
||||
{
|
||||
await SeedPlayout(1, version: 1);
|
||||
var handler = new ReplacePlayoutAlternateScheduleItemsHandler(
|
||||
_db.Factory,
|
||||
_worker,
|
||||
NullLogger<ReplacePlayoutAlternateScheduleItemsHandler>.Instance);
|
||||
|
||||
// TWO items: index 1 is the catch-all (highest index), so index 0 is a real alternate whose
|
||||
// recurrence IS stored. The empty set goes on THAT one.
|
||||
ReplacePlayoutAlternateSchedule empty = AltItem(index: 0) with { DaysOfWeek = [] };
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new ReplacePlayoutAlternateScheduleItems(1, [empty, AltItem(index: 1)]),
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).Value.ShouldContain("[DaysOfWeek]");
|
||||
LeftOf(result).Value.ShouldContain("no day of the week");
|
||||
|
||||
// rejected BEFORE any mutation -- the version bump is the observable proof nothing was written
|
||||
(await ReadPlayoutVersion(1)).ShouldBe(1);
|
||||
}
|
||||
|
||||
// The catch-all's recurrence is discarded by the handler (only its ProgramScheduleId is used), so an
|
||||
// empty set there cannot make anything "never apply". Rejecting it would state a reason that is FALSE
|
||||
// for that item, which is why the check walks `incoming` rather than every submitted item.
|
||||
[Test]
|
||||
public async Task ReplaceAlternateSchedules_Should_Allow_An_Empty_Recurrence_On_The_CatchAll_Item()
|
||||
{
|
||||
await SeedPlayout(1, version: 1);
|
||||
var handler = new ReplacePlayoutAlternateScheduleItemsHandler(
|
||||
_db.Factory,
|
||||
_worker,
|
||||
NullLogger<ReplacePlayoutAlternateScheduleItemsHandler>.Instance);
|
||||
|
||||
// a single item IS the catch-all
|
||||
ReplacePlayoutAlternateSchedule catchAll = AltItem(index: 0) with { DaysOfWeek = [], MonthsOfYear = [] };
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new ReplacePlayoutAlternateScheduleItems(1, [catchAll]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
(await ReadPlayoutVersion(1)).ShouldBe(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceTemplates_Should_Reject_A_Newly_Empty_Recurrence()
|
||||
{
|
||||
await SeedPlayout(1, version: 1);
|
||||
var handler = new ReplacePlayoutTemplateItemsHandler(
|
||||
_db.Factory,
|
||||
NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);
|
||||
|
||||
ReplacePlayoutTemplate empty = TemplateItem() with { DaysOfMonth = [] };
|
||||
|
||||
Option<BaseError> result = await handler.Handle(
|
||||
new ReplacePlayoutTemplateItems(1, [empty]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IfNone(() => throw new AssertionException("Expected a Some(error)"))
|
||||
.Value.ShouldContain("[DaysOfMonth]");
|
||||
(await ReadPlayoutVersion(1)).ShouldBe(1);
|
||||
}
|
||||
|
||||
// `api.ffmpeg-profile-numeric-bounds`: reject a NEWLY submitted bad value, not an UNCHANGED one the row
|
||||
// already holds. Both PUT paths are whole-list replaces, so without this a single pre-existing empty row
|
||||
// would make every OTHER item in the playout uneditable.
|
||||
[Test]
|
||||
public async Task ReplaceTemplates_Should_Allow_An_UNCHANGED_Empty_Recurrence_That_Is_Already_Stored()
|
||||
{
|
||||
int templateItemId = await SeedPlayoutWithEmptyTemplateRecurrence();
|
||||
var handler = new ReplacePlayoutTemplateItemsHandler(
|
||||
_db.Factory,
|
||||
NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);
|
||||
|
||||
// same row, same empty DaysOfWeek -- an edit to some OTHER field on the same list
|
||||
ReplacePlayoutTemplate unchanged = TemplateItem() with { Id = templateItemId, DaysOfWeek = [] };
|
||||
|
||||
Option<BaseError> result = await handler.Handle(
|
||||
new ReplacePlayoutTemplateItems(1, [unchanged]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
(await ReadPlayoutVersion(1)).ShouldBe(2);
|
||||
}
|
||||
|
||||
// ... and the complement: the SAME stored row rejects a DIFFERENT field being newly emptied, so the
|
||||
// exemption is per-field rather than "this row is grandfathered".
|
||||
[Test]
|
||||
public async Task ReplaceTemplates_Should_Still_Reject_A_Different_Field_Newly_Emptied_On_A_Stored_Row()
|
||||
{
|
||||
int templateItemId = await SeedPlayoutWithEmptyTemplateRecurrence();
|
||||
var handler = new ReplacePlayoutTemplateItemsHandler(
|
||||
_db.Factory,
|
||||
NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);
|
||||
|
||||
// DaysOfWeek is the stored-empty one; MonthsOfYear is stored FULL, so emptying it is new
|
||||
ReplacePlayoutTemplate item = TemplateItem() with
|
||||
{
|
||||
Id = templateItemId,
|
||||
DaysOfWeek = [],
|
||||
MonthsOfYear = []
|
||||
};
|
||||
|
||||
Option<BaseError> result = await handler.Handle(
|
||||
new ReplacePlayoutTemplateItems(1, [item]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IfNone(() => throw new AssertionException("Expected a Some(error)"))
|
||||
.Value.ShouldContain("[MonthsOfYear]");
|
||||
(await ReadPlayoutVersion(1)).ShouldBe(1);
|
||||
}
|
||||
|
||||
private async Task<int> SeedPlayoutWithEmptyTemplateRecurrence()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
// The handler loads templates with `.Include(p => p.Templates).ThenInclude(t => t.Template)`, and
|
||||
// that navigation is required -- so a PlayoutTemplate whose Template row does not exist is joined
|
||||
// OUT and never reaches `existing`. Without seeding this, the stored row is invisible, the
|
||||
// exemption cannot match, and the test fails for a reason that has nothing to do with the rule.
|
||||
context.TemplateGroups.Add(new TemplateGroup { Id = 5, Name = "Group", Templates = [] });
|
||||
context.Templates.Add(new Template { Id = 20, TemplateGroupId = 5, Name = "Template 20", Items = [] });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var template = new PlayoutTemplate
|
||||
{
|
||||
Index = 0,
|
||||
TemplateId = 20,
|
||||
DaysOfWeek = [],
|
||||
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
|
||||
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
|
||||
LimitToDateRange = false,
|
||||
StartMonth = 1,
|
||||
StartDay = 1,
|
||||
EndMonth = 12,
|
||||
EndDay = 31
|
||||
};
|
||||
context.Playouts.Add(
|
||||
new Playout
|
||||
{
|
||||
Id = 1,
|
||||
ChannelId = 1,
|
||||
ProgramScheduleId = 10,
|
||||
Version = 1,
|
||||
Items = [],
|
||||
ProgramScheduleAlternates = [],
|
||||
Templates = [template]
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
return template.Id;
|
||||
}
|
||||
|
||||
// ---- #253 optimistic concurrency (alternate schedules #7 + templates #8, shared Playout.Version) ----
|
||||
|
||||
private static ReplacePlayoutAlternateSchedule AltItem(int programScheduleId = 10) =>
|
||||
new(0, 0, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
|
||||
// Recurrence sets are UNRESTRICTED here, not empty (#880): an empty set now means "matches no date"
|
||||
// and is rejected on any item whose recurrence is stored, so an empty fixture would make these
|
||||
// concurrency tests measure the recurrence guard instead of the version check.
|
||||
private static ReplacePlayoutAlternateSchedule AltItem(int programScheduleId = 10, int index = 0) =>
|
||||
new(
|
||||
0,
|
||||
index,
|
||||
programScheduleId,
|
||||
AlternateScheduleSelector.AllDaysOfWeek(),
|
||||
AlternateScheduleSelector.AllDaysOfMonth(),
|
||||
AlternateScheduleSelector.AllMonthsOfYear(),
|
||||
false,
|
||||
1,
|
||||
1,
|
||||
null,
|
||||
12,
|
||||
31,
|
||||
null);
|
||||
|
||||
private static ReplacePlayoutTemplate TemplateItem(int templateId = 20) =>
|
||||
new(0, 0, templateId, null, [], [], [], false, 1, 1, null, 12, 31, null);
|
||||
new(
|
||||
0,
|
||||
0,
|
||||
templateId,
|
||||
null,
|
||||
AlternateScheduleSelector.AllDaysOfWeek(),
|
||||
AlternateScheduleSelector.AllDaysOfMonth(),
|
||||
AlternateScheduleSelector.AllMonthsOfYear(),
|
||||
false,
|
||||
1,
|
||||
1,
|
||||
null,
|
||||
12,
|
||||
31,
|
||||
null);
|
||||
|
||||
private async Task SeedPlayout(int id, int version, int? programScheduleId = 10)
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -978,7 +978,7 @@ public class PlayoutControllerTests
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
|
||||
var request = new ReplacePlayoutAlternateSchedulesRequest(
|
||||
[new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]);
|
||||
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, true, startMonth, startDay, null, endMonth, endDay, null)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
|
||||
|
||||
@@ -1002,7 +1002,7 @@ public class PlayoutControllerTests
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
|
||||
var request = new ReplacePlayoutAlternateSchedulesRequest(
|
||||
[new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]);
|
||||
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, true, startMonth, startDay, null, endMonth, endDay, null)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
|
||||
|
||||
@@ -1025,7 +1025,7 @@ public class PlayoutControllerTests
|
||||
|
||||
// LimitToDateRange is false, so the out-of-range month/day here must not block the save.
|
||||
var request = new ReplacePlayoutAlternateSchedulesRequest(
|
||||
[new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], false, 0, 0, null, 13, 32, null)]);
|
||||
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, false, 0, 0, null, 13, 32, null)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
|
||||
|
||||
@@ -1079,6 +1079,110 @@ public class PlayoutControllerTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ----- #880: absent recurrence means UNRESTRICTED, an explicit [] is rejected -----
|
||||
|
||||
// Reddens if ToReplaceItem's `?? All*()` is reverted to `?? []`: the counts drop to 0. That is the
|
||||
// point of the test -- the normalization is the fix, so it is what must be pinned.
|
||||
[Test]
|
||||
public async Task ReplaceAlternateSchedules_Should_Normalize_Absent_Recurrence_To_Unrestricted()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
_mediator.Send(Arg.Any<GetAllProgramSchedules>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeScheduleVm(7)]);
|
||||
_mediator.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_mediator.Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeAltVm(1, 0, 7)]);
|
||||
|
||||
// All three recurrence arrays omitted -- the shape an API client sends and the SPA never does.
|
||||
var request = new ReplacePlayoutAlternateSchedulesRequest(
|
||||
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, false, 1, 1, null, 12, 31, null)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplacePlayoutAlternateScheduleItems>(c =>
|
||||
c.Items.Count == 1 &&
|
||||
c.Items[0].DaysOfWeek.Count == 7 &&
|
||||
c.Items[0].DaysOfMonth.Count == 31 &&
|
||||
c.Items[0].MonthsOfYear.Count == 12),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// The complement of the test above: normalization must fill in ONLY what was absent. Without this a
|
||||
// fix that substituted All*() unconditionally would still pass the normalization test.
|
||||
[Test]
|
||||
public async Task ReplaceAlternateSchedules_Should_Preserve_An_Explicit_Recurrence_Selection()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
_mediator.Send(Arg.Any<GetAllProgramSchedules>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeScheduleVm(7)]);
|
||||
_mediator.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_mediator.Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeAltVm(1, 0, 7)]);
|
||||
|
||||
var request = new ReplacePlayoutAlternateSchedulesRequest(
|
||||
[
|
||||
new PlayoutAlternateScheduleItemRequest(
|
||||
0,
|
||||
7,
|
||||
[DayOfWeek.Monday, DayOfWeek.Tuesday],
|
||||
null,
|
||||
[6],
|
||||
false,
|
||||
1,
|
||||
1,
|
||||
null,
|
||||
12,
|
||||
31,
|
||||
null)
|
||||
]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplacePlayoutAlternateScheduleItems>(c =>
|
||||
c.Items[0].DaysOfWeek.Count == 2 &&
|
||||
c.Items[0].DaysOfWeek.Contains(DayOfWeek.Monday) &&
|
||||
c.Items[0].DaysOfMonth.Count == 31 &&
|
||||
c.Items[0].MonthsOfYear.Count == 1 &&
|
||||
c.Items[0].MonthsOfYear.Contains(6)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceTemplates_Should_Normalize_Absent_Recurrence_To_Unrestricted()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
||||
_mediator.Send(Arg.Any<GetAllTemplates>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeTemplateViewModel(7)]);
|
||||
_mediator.Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.None);
|
||||
_mediator.Send(Arg.Any<GetPlayoutTemplates>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeTemplateVm(1, 0, 7, null)]);
|
||||
|
||||
var request = new ReplacePlayoutTemplatesRequest(
|
||||
[new PlayoutTemplateItemRequest(0, 7, null, null, null, null, false, 1, 1, null, 12, 31, null)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplacePlayoutTemplateItems>(c =>
|
||||
c.Items.Count == 1 &&
|
||||
c.Items[0].DaysOfWeek.Count == 7 &&
|
||||
c.Items[0].DaysOfMonth.Count == 31 &&
|
||||
c.Items[0].MonthsOfYear.Count == 12),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ----- Playout templates -----
|
||||
|
||||
[Test]
|
||||
@@ -1187,7 +1291,7 @@ public class PlayoutControllerTests
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
||||
|
||||
var request = new ReplacePlayoutTemplatesRequest(
|
||||
[new PlayoutTemplateItemRequest(0, 7, null, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]);
|
||||
[new PlayoutTemplateItemRequest(0, 7, null, null, null, null, true, startMonth, startDay, null, endMonth, endDay, null)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None);
|
||||
|
||||
@@ -1212,7 +1316,7 @@ public class PlayoutControllerTests
|
||||
|
||||
// LimitToDateRange is false, so the out-of-range month/day here must not block the save.
|
||||
var request = new ReplacePlayoutTemplatesRequest(
|
||||
[new PlayoutTemplateItemRequest(0, 7, null, [], [], [], false, 0, 0, null, 13, 32, null)]);
|
||||
[new PlayoutTemplateItemRequest(0, 7, null, null, null, null, false, 0, 0, null, 13, 32, null)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None);
|
||||
|
||||
@@ -1400,7 +1504,7 @@ public class PlayoutControllerTests
|
||||
new(id, index, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
|
||||
|
||||
private static PlayoutAlternateScheduleItemRequest MakeAltRequest(int programScheduleId) =>
|
||||
new(0, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
|
||||
new(0, programScheduleId, null, null, null, false, 1, 1, null, 12, 31, null);
|
||||
|
||||
private static ProgramScheduleViewModel MakeScheduleVm(int id) =>
|
||||
new(id, $"Schedule {id}", false, false, false, false, FixedStartTimeBehavior.Strict, null, 0);
|
||||
@@ -1426,7 +1530,7 @@ public class PlayoutControllerTests
|
||||
null);
|
||||
|
||||
private static PlayoutTemplateItemRequest MakeTemplateRequest(int templateId, int? decoTemplateId) =>
|
||||
new(0, templateId, decoTemplateId, [], [], [], false, 1, 1, null, 12, 31, null);
|
||||
new(0, templateId, decoTemplateId, null, null, null, false, 1, 1, null, 12, 31, null);
|
||||
|
||||
private static PlayoutNameViewModel MakePlayout(int id) =>
|
||||
new(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -726,6 +726,285 @@ public class JellyfinMusicVideoLibraryScannerTests
|
||||
metadata.Artists.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ersatztv#924: Jellyfin's library-items projection carries no streams and no width/height — those come only
|
||||
// from the per-item playback info. These run against the REAL MetadataRepository so the assertions read what
|
||||
// was persisted. Non-vacuous (measured 2026-10-21): restoring the scanner's former body,
|
||||
// `UpdateStatistics(result.Item, incoming.GetHeadVersion())`, reddens all five tests below.
|
||||
[Test]
|
||||
public async Task ScanLibrary_Should_Store_PlaybackInfo_Statistics_For_A_New_MusicVideo()
|
||||
{
|
||||
int libraryPathId = await SeedLibraryPath("/data/music");
|
||||
const string VideoPath = "/data/music/artist1/song1.mkv";
|
||||
|
||||
JellyfinMusicVideo Incoming()
|
||||
{
|
||||
JellyfinMusicVideo musicVideo = BuildIncoming(VideoPath, "Artist 1", "Song 1");
|
||||
musicVideo.MediaVersions[0].Chapters = [Chapter(0, "Intro")];
|
||||
return musicVideo;
|
||||
}
|
||||
|
||||
var playbackCalls = new List<string>();
|
||||
IJellyfinApiClient api = FakeApiWithPlaybackInfo(
|
||||
itemId =>
|
||||
{
|
||||
playbackCalls.Add(itemId);
|
||||
return PlaybackInfo(1920, 1080);
|
||||
},
|
||||
Incoming);
|
||||
|
||||
(JellyfinMusicVideoLibraryScanner scanner, _) =
|
||||
BuildScanner(api, metadataRepository: new MetadataRepository(_db.Factory));
|
||||
|
||||
(await scanner.ScanLibrary(
|
||||
ConnectionParameters,
|
||||
BuildLibrary(libraryPathId, "/data/music"),
|
||||
deepScan: false,
|
||||
CancellationToken.None)).IsRight.ShouldBeTrue();
|
||||
|
||||
playbackCalls.ShouldBe([ItemIdFor(VideoPath)]);
|
||||
|
||||
MediaVersion version = await HeadVersionOf(VideoPath);
|
||||
version.Streams.Count(s => s.MediaStreamKind == MediaStreamKind.Video).ShouldBe(1);
|
||||
version.Streams.Count(s => s.MediaStreamKind == MediaStreamKind.Audio).ShouldBe(1);
|
||||
version.Width.ShouldBe(1920);
|
||||
version.Height.ShouldBe(1080);
|
||||
|
||||
// the projection's chapters are stored alongside the statistics, not dropped by them
|
||||
version.Chapters.Select(c => c.Title).ShouldBe(["Intro"]);
|
||||
}
|
||||
|
||||
// The remediation path for rows already stored without streams: a regular scan skips an item whose etag is
|
||||
// unchanged (so it cannot heal them), and a deep scan re-reads the statistics.
|
||||
[Test]
|
||||
public async Task ScanLibrary_Deep_Scan_Should_Read_Statistics_For_An_Unchanged_MusicVideo_Without_Streams()
|
||||
{
|
||||
int libraryPathId = await SeedLibraryPath("/data/music");
|
||||
JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music");
|
||||
const string VideoPath = "/data/music/artist1/song1.mkv";
|
||||
|
||||
var playbackCalls = 0;
|
||||
var serverAnswers = false;
|
||||
(int Width, int Height) size = (1280, 720);
|
||||
IJellyfinApiClient api = FakeApiWithPlaybackInfo(
|
||||
_ =>
|
||||
{
|
||||
playbackCalls++;
|
||||
return serverAnswers
|
||||
? PlaybackInfo(size.Width, size.Height)
|
||||
: BaseError.New("playback info unavailable");
|
||||
},
|
||||
() => BuildIncoming(VideoPath, "Artist 1", "Song 1"));
|
||||
|
||||
(JellyfinMusicVideoLibraryScanner scanner, _) =
|
||||
BuildScanner(api, metadataRepository: new MetadataRepository(_db.Factory));
|
||||
|
||||
// 1. the item is added while the server cannot supply statistics: stored with no streams
|
||||
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
|
||||
.IsRight.ShouldBeTrue();
|
||||
(await HeadVersionOf(VideoPath)).Streams.ShouldBeEmpty();
|
||||
playbackCalls.ShouldBe(1);
|
||||
|
||||
// 2. a regular scan of the unchanged item does not look at it again
|
||||
serverAnswers = true;
|
||||
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
|
||||
.IsRight.ShouldBeTrue();
|
||||
playbackCalls.ShouldBe(1);
|
||||
(await HeadVersionOf(VideoPath)).Streams.ShouldBeEmpty();
|
||||
|
||||
// 3. a deep scan reads the statistics
|
||||
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: true, CancellationToken.None))
|
||||
.IsRight.ShouldBeTrue();
|
||||
playbackCalls.ShouldBe(2);
|
||||
MediaVersion version = await HeadVersionOf(VideoPath);
|
||||
version.Streams.Count(s => s.MediaStreamKind == MediaStreamKind.Video).ShouldBe(1);
|
||||
version.Width.ShouldBe(1280);
|
||||
version.Height.ShouldBe(720);
|
||||
|
||||
// 4. a deep scan re-reads them even when streams are already stored and the etag is unchanged
|
||||
size = (1920, 1080);
|
||||
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: true, CancellationToken.None))
|
||||
.IsRight.ShouldBeTrue();
|
||||
playbackCalls.ShouldBe(3);
|
||||
(await HeadVersionOf(VideoPath)).Width.ShouldBe(1920);
|
||||
}
|
||||
|
||||
// The "no stored streams" arm of the refresh condition: an Unavailable item whose file is back is scanned with
|
||||
// an UNCHANGED etag and no deep scan, so only the empty stream list says its statistics must be read.
|
||||
[Test]
|
||||
public async Task ScanLibrary_Should_Read_Statistics_For_A_Returning_Unavailable_MusicVideo_Without_Streams()
|
||||
{
|
||||
int libraryPathId = await SeedLibraryPath("/data/music");
|
||||
JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music");
|
||||
const string VideoPath = "/data/music/artist1/song1.mkv";
|
||||
|
||||
var serverAnswers = false;
|
||||
IJellyfinApiClient api = FakeApiWithPlaybackInfo(
|
||||
_ => serverAnswers ? PlaybackInfo(1280, 720) : BaseError.New("playback info unavailable"),
|
||||
() => BuildIncoming(VideoPath, "Artist 1", "Song 1"));
|
||||
|
||||
(JellyfinMusicVideoLibraryScanner scanner, _) =
|
||||
BuildScanner(api, metadataRepository: new MetadataRepository(_db.Factory));
|
||||
|
||||
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
|
||||
.IsRight.ShouldBeTrue();
|
||||
|
||||
int musicVideoId = await MusicVideoId(VideoPath);
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
MediaItem mediaItem = await context.MediaItems.SingleAsync(mi => mi.Id == musicVideoId);
|
||||
mediaItem.State = MediaItemState.Unavailable;
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
serverAnswers = true;
|
||||
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
|
||||
.IsRight.ShouldBeTrue();
|
||||
|
||||
MediaVersion version = await HeadVersionOf(VideoPath);
|
||||
version.Streams.Count(s => s.MediaStreamKind == MediaStreamKind.Video).ShouldBe(1);
|
||||
version.Width.ShouldBe(1280);
|
||||
(await MediaItemStateOf(musicVideoId)).ShouldBe(MediaItemState.Normal);
|
||||
}
|
||||
|
||||
// When the server cannot supply statistics on a rescan, nothing is written: the projection's empty stream
|
||||
// list must not replace the streams already stored (IMetadataRepository.UpdateStatistics removes every
|
||||
// stored stream the version it is given does not carry).
|
||||
[Test]
|
||||
public async Task ScanLibrary_Should_Keep_Stored_Statistics_When_PlaybackInfo_Fails_On_Rescan()
|
||||
{
|
||||
int libraryPathId = await SeedLibraryPath("/data/music");
|
||||
JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music");
|
||||
const string VideoPath = "/data/music/artist1/song1.mkv";
|
||||
|
||||
var title = "Song 1";
|
||||
IJellyfinApiClient api = FakeApiWithPlaybackInfo(
|
||||
_ => BaseError.New("playback info unavailable"),
|
||||
() => BuildIncoming(VideoPath, "Artist 1", title));
|
||||
|
||||
(JellyfinMusicVideoLibraryScanner scanner, _) =
|
||||
BuildScanner(api, metadataRepository: new MetadataRepository(_db.Factory));
|
||||
|
||||
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
|
||||
.IsRight.ShouldBeTrue();
|
||||
|
||||
// the row holds statistics from an earlier successful read
|
||||
int versionId = (await HeadVersionOf(VideoPath)).Id;
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
MediaVersion stored = await context.MediaVersions.SingleAsync(v => v.Id == versionId);
|
||||
stored.Width = 1920;
|
||||
stored.Height = 1080;
|
||||
await context.MediaStreams.AddAsync(
|
||||
new MediaStream
|
||||
{
|
||||
MediaVersionId = versionId,
|
||||
Index = 0,
|
||||
MediaStreamKind = MediaStreamKind.Video,
|
||||
Codec = "h264"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// the server reports the item changed (BuildIncoming derives the etag from the title), but playback
|
||||
// info fails
|
||||
title = "Song 1 (Remastered)";
|
||||
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
|
||||
.IsRight.ShouldBeTrue();
|
||||
|
||||
MediaVersion version = await HeadVersionOf(VideoPath);
|
||||
version.Streams.Select(s => (s.Index, s.MediaStreamKind, s.Codec))
|
||||
.ShouldBe([(0, MediaStreamKind.Video, "h264")]);
|
||||
version.Width.ShouldBe(1920);
|
||||
version.Height.ShouldBe(1080);
|
||||
}
|
||||
|
||||
// Refreshed statistics take their chapters from the INCOMING projection: the repository does not eager-load
|
||||
// chapters on an existing row, so JellyfinMovieLibraryScanner's source (the stored item) would have none to
|
||||
// write.
|
||||
[Test]
|
||||
public async Task ScanLibrary_Should_Refresh_Statistics_And_Projection_Chapters_When_The_Etag_Changes()
|
||||
{
|
||||
int libraryPathId = await SeedLibraryPath("/data/music");
|
||||
JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music");
|
||||
const string VideoPath = "/data/music/artist1/song1.mkv";
|
||||
|
||||
var title = "Song 1";
|
||||
List<MediaChapter> chapters = [Chapter(0, "Intro")];
|
||||
(int Width, int Height) size = (1280, 720);
|
||||
|
||||
JellyfinMusicVideo Incoming()
|
||||
{
|
||||
JellyfinMusicVideo musicVideo = BuildIncoming(VideoPath, "Artist 1", title);
|
||||
musicVideo.MediaVersions[0].Chapters = chapters.Select(c => Chapter(c.ChapterId, c.Title)).ToList();
|
||||
return musicVideo;
|
||||
}
|
||||
|
||||
IJellyfinApiClient api = FakeApiWithPlaybackInfo(_ => PlaybackInfo(size.Width, size.Height), Incoming);
|
||||
(JellyfinMusicVideoLibraryScanner scanner, _) =
|
||||
BuildScanner(api, metadataRepository: new MetadataRepository(_db.Factory));
|
||||
|
||||
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
|
||||
.IsRight.ShouldBeTrue();
|
||||
|
||||
title = "Song 1 (Remastered)";
|
||||
chapters = [Chapter(0, "Intro"), Chapter(1, "Chorus")];
|
||||
size = (1920, 1080);
|
||||
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
|
||||
.IsRight.ShouldBeTrue();
|
||||
|
||||
MediaVersion version = await HeadVersionOf(VideoPath);
|
||||
version.Width.ShouldBe(1920);
|
||||
version.Height.ShouldBe(1080);
|
||||
version.Streams.Count(s => s.MediaStreamKind == MediaStreamKind.Video).ShouldBe(1);
|
||||
version.Chapters.OrderBy(c => c.ChapterId).Select(c => c.Title).ShouldBe(["Intro", "Chorus"]);
|
||||
}
|
||||
|
||||
private static MediaChapter Chapter(long chapterId, string title) =>
|
||||
new()
|
||||
{
|
||||
ChapterId = chapterId,
|
||||
Title = title,
|
||||
StartTime = TimeSpan.FromSeconds(chapterId * 30),
|
||||
EndTime = TimeSpan.FromSeconds((chapterId + 1) * 30)
|
||||
};
|
||||
|
||||
// What JellyfinApiClient.GetPlaybackInfo returns for a playable file: a version carrying the streams and the
|
||||
// dimensions. Built fresh per call, since UpdateStatistics attaches the stream objects to the stored version.
|
||||
private static Either<BaseError, MediaVersion> PlaybackInfo(int width, int height) =>
|
||||
new MediaVersion
|
||||
{
|
||||
Duration = TimeSpan.FromMinutes(4),
|
||||
Width = width,
|
||||
Height = height,
|
||||
SampleAspectRatio = "1:1",
|
||||
DisplayAspectRatio = "16:9",
|
||||
RFrameRate = "23.976",
|
||||
VideoScanKind = VideoScanKind.Progressive,
|
||||
Streams =
|
||||
[
|
||||
new MediaStream { Index = 0, MediaStreamKind = MediaStreamKind.Video, Codec = "h264" },
|
||||
new MediaStream { Index = 1, MediaStreamKind = MediaStreamKind.Audio, Codec = "aac", Channels = 2 }
|
||||
],
|
||||
Chapters = []
|
||||
};
|
||||
|
||||
private async Task<MediaVersion> HeadVersionOf(string path)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
List<MusicVideo> musicVideos = await context.MusicVideos
|
||||
.Include(mv => mv.MediaVersions)
|
||||
.ThenInclude(v => v.MediaFiles)
|
||||
.Include(mv => mv.MediaVersions)
|
||||
.ThenInclude(v => v.Streams)
|
||||
.Include(mv => mv.MediaVersions)
|
||||
.ThenInclude(v => v.Chapters)
|
||||
.ToListAsync();
|
||||
return musicVideos
|
||||
.Single(mv => mv.MediaVersions.Single().MediaFiles.Single().Path == path)
|
||||
.MediaVersions
|
||||
.Single();
|
||||
}
|
||||
|
||||
private static JellyfinConnectionParameters ConnectionParameters =>
|
||||
new("http://jellyfin", "api-key", 1);
|
||||
|
||||
@@ -810,7 +1089,8 @@ public class JellyfinMusicVideoLibraryScannerTests
|
||||
|
||||
private (JellyfinMusicVideoLibraryScanner Scanner, IScannerProxy ScannerProxy) BuildScanner(
|
||||
IJellyfinApiClient apiClient,
|
||||
Func<string, string> replacePath = null)
|
||||
Func<string, string> replacePath = null,
|
||||
IMetadataRepository metadataRepository = null)
|
||||
{
|
||||
IScannerProxy scannerProxy = BuildScannerProxy();
|
||||
|
||||
@@ -823,7 +1103,7 @@ public class JellyfinMusicVideoLibraryScannerTests
|
||||
new ArtistRepository(_db.Factory),
|
||||
new MusicVideoRepository(_db.Factory),
|
||||
new LibraryRepository(Substitute.For<IFileSystem>(), _db.Factory),
|
||||
Substitute.For<IMetadataRepository>(),
|
||||
metadataRepository ?? Substitute.For<IMetadataRepository>(),
|
||||
BuildFileSystem(),
|
||||
NullLogger<JellyfinMusicVideoLibraryScanner>.Instance);
|
||||
|
||||
@@ -912,7 +1192,14 @@ public class JellyfinMusicVideoLibraryScannerTests
|
||||
|
||||
// Each Func builds a fresh music video so the async stream can be re-enumerated across ScanLibrary calls
|
||||
// (an IAsyncEnumerable iterator is single-use, and the scanner mutates the incoming item).
|
||||
private static IJellyfinApiClient FakeApi(params Func<JellyfinMusicVideo>[] items)
|
||||
// Playback info succeeds by default (ersatztv#924: the scanner reads statistics from it for every scanned item).
|
||||
private static IJellyfinApiClient FakeApi(params Func<JellyfinMusicVideo>[] items) =>
|
||||
FakeApiWithPlaybackInfo(_ => PlaybackInfo(1920, 1080), items);
|
||||
|
||||
// `playbackInfo` answers GetPlaybackInfo per item id, standing in for the server's per-item playback info.
|
||||
private static IJellyfinApiClient FakeApiWithPlaybackInfo(
|
||||
Func<string, Either<BaseError, MediaVersion>> playbackInfo,
|
||||
params Func<JellyfinMusicVideo>[] items)
|
||||
{
|
||||
var apiClient = Substitute.For<IJellyfinApiClient>();
|
||||
apiClient.GetMusicVideoLibraryItems(
|
||||
@@ -921,9 +1208,16 @@ public class JellyfinMusicVideoLibraryScannerTests
|
||||
Arg.Any<JellyfinLibrary>(),
|
||||
Arg.Any<MediaServerProjectionFailureCounter>())
|
||||
.Returns(_ => Items(items));
|
||||
StubPlaybackInfo(apiClient, playbackInfo);
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
private static void StubPlaybackInfo(
|
||||
IJellyfinApiClient apiClient,
|
||||
Func<string, Either<BaseError, MediaVersion>> playbackInfo) =>
|
||||
apiClient.GetPlaybackInfo(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<JellyfinLibrary>(), Arg.Any<string>())
|
||||
.Returns(ci => Task.FromResult(playbackInfo(ci.ArgAt<string>(3))));
|
||||
|
||||
// Same as FakeApi, but the enumeration also reports `failures` swallowed projection exceptions into the
|
||||
// counter the scanner handed in — what the real JellyfinApiClient does from ProjectToMusicVideo's catch.
|
||||
private static IJellyfinApiClient FakeApiWithProjectionFailures(
|
||||
@@ -940,6 +1234,7 @@ public class JellyfinMusicVideoLibraryScannerTests
|
||||
items,
|
||||
ci.ArgAt<MediaServerProjectionFailureCounter>(3),
|
||||
failures));
|
||||
StubPlaybackInfo(apiClient, _ => PlaybackInfo(1920, 1080));
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record PlayoutAlternateScheduleItemRequest(
|
||||
int Id,
|
||||
int ProgramScheduleId,
|
||||
List<DayOfWeek> DaysOfWeek,
|
||||
List<int> DaysOfMonth,
|
||||
List<int> MonthsOfYear,
|
||||
List<DayOfWeek>? DaysOfWeek,
|
||||
List<int>? DaysOfMonth,
|
||||
List<int>? MonthsOfYear,
|
||||
bool LimitToDateRange,
|
||||
int StartMonth,
|
||||
int StartDay,
|
||||
@@ -16,14 +18,26 @@ public record PlayoutAlternateScheduleItemRequest(
|
||||
int EndDay,
|
||||
int? EndYear)
|
||||
{
|
||||
// An ABSENT recurrence array means UNRESTRICTED, not "matches nothing" (#880). The three sets are
|
||||
// conjunctive in AlternateScheduleSelector.GetScheduleForDate -- a miss on any one `continue`s -- so
|
||||
// the previous `?? []` stored an item that could never apply on any date, and returned 200 while
|
||||
// doing it. Absence now normalizes to the same All*() sets the READ side substitutes for a NULL
|
||||
// column (AlternateScheduleSelector's read guard and both Mapper.ProjectToViewModel overloads), so
|
||||
// the two halves of "this field is absent" finally agree -- residual (3) of
|
||||
// `media.nullable-primitive-collection-mutation`.
|
||||
//
|
||||
// These are nullable so that ABSENT is distinguishable from an explicitly-sent `[]`, which is a
|
||||
// different request and is REJECTED with a 422 in PlayoutController rather than normalized here.
|
||||
// Newtonsoft maps both a missing property and an explicit `null` to null, so both read as absent;
|
||||
// only a literal `[]` survives as empty.
|
||||
public ReplacePlayoutAlternateSchedule ToReplaceItem(int index) =>
|
||||
new(
|
||||
Id,
|
||||
index,
|
||||
ProgramScheduleId,
|
||||
DaysOfWeek ?? [],
|
||||
DaysOfMonth ?? [],
|
||||
MonthsOfYear ?? [],
|
||||
DaysOfWeek ?? AlternateScheduleSelector.AllDaysOfWeek(),
|
||||
DaysOfMonth ?? AlternateScheduleSelector.AllDaysOfMonth(),
|
||||
MonthsOfYear ?? AlternateScheduleSelector.AllMonthsOfYear(),
|
||||
LimitToDateRange,
|
||||
StartMonth,
|
||||
StartDay,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
@@ -6,9 +8,9 @@ public record PlayoutTemplateItemRequest(
|
||||
int Id,
|
||||
int TemplateId,
|
||||
int? DecoTemplateId,
|
||||
List<DayOfWeek> DaysOfWeek,
|
||||
List<int> DaysOfMonth,
|
||||
List<int> MonthsOfYear,
|
||||
List<DayOfWeek>? DaysOfWeek,
|
||||
List<int>? DaysOfMonth,
|
||||
List<int>? MonthsOfYear,
|
||||
bool LimitToDateRange,
|
||||
int StartMonth,
|
||||
int StartDay,
|
||||
@@ -17,15 +19,18 @@ public record PlayoutTemplateItemRequest(
|
||||
int EndDay,
|
||||
int? EndYear)
|
||||
{
|
||||
// Same contract as PlayoutAlternateScheduleItemRequest: ABSENT means UNRESTRICTED (the All*() sets
|
||||
// the read side substitutes for a NULL column), an explicit `[]` is rejected with a 422 in
|
||||
// PlayoutController. See that record for the full rationale (#880).
|
||||
public ReplacePlayoutTemplate ToReplaceItem(int index) =>
|
||||
new(
|
||||
Id,
|
||||
index,
|
||||
TemplateId,
|
||||
DecoTemplateId,
|
||||
DaysOfWeek ?? [],
|
||||
DaysOfMonth ?? [],
|
||||
MonthsOfYear ?? [],
|
||||
DaysOfWeek ?? AlternateScheduleSelector.AllDaysOfWeek(),
|
||||
DaysOfMonth ?? AlternateScheduleSelector.AllDaysOfMonth(),
|
||||
MonthsOfYear ?? AlternateScheduleSelector.AllMonthsOfYear(),
|
||||
LimitToDateRange,
|
||||
StartMonth,
|
||||
StartDay,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+97
-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
|
||||
@@ -383,6 +409,46 @@ configurable — never materialized into a stored number on save, so an untouche
|
||||
identically. Document the range in the schema with `[property: Description("…")]` on the request
|
||||
record's positional parameter (`System.ComponentModel`); it renders into `v1.json`.
|
||||
|
||||
### 3e. An ABSENT collection means unrestricted; an explicitly EMPTY one is rejected
|
||||
|
||||
A nullable collection on a write path carries two different requests that are easy to collapse into
|
||||
one, and collapsing them is how ersatztv#880 shipped a 200 that stored a row which could never apply.
|
||||
Decide both, separately:
|
||||
|
||||
- **Absent** (the property is missing, or explicitly `null` — Newtonsoft maps both to `null`) means
|
||||
*the client is not expressing a restriction*. Normalize it to the **permissive** value, which is
|
||||
whatever the READ side already substitutes for the same absence. It must not become the empty set:
|
||||
for a filter, empty is the maximally *restrictive* value, so `?? []` silently inverts the request.
|
||||
- **Explicitly empty** (`[]`) is a different, well-formed request. If the empty set has no meaningful
|
||||
outcome — a conjunctive filter that then matches nothing — **reject it with a 422 naming the
|
||||
consequence**, per §3d. Do not accept-then-rewrite it into the permissive value: that would make
|
||||
`[]` and absence indistinguishable again, in the other direction.
|
||||
|
||||
Distinguishing them requires the request record's property to be **nullable** (`List<T>?`, with a
|
||||
per-file `#nullable enable` where the project has annotations off), because `?? …` on a
|
||||
non-nullable-annotated `List<T>` cannot tell absence from empty. Normalization belongs in the request
|
||||
record's `ToReplaceItem`; **rejection does not belong beside it.** Three rules that come with it:
|
||||
|
||||
- **Say "send `null`", not "omit the property".** Making a C# property nullable does NOT make it
|
||||
optional in the generated schema: all three recurrence properties are still listed in `required` in
|
||||
`v1.json`, with type `["null","array"]`. A client generated from the published contract therefore
|
||||
*cannot* omit them, and an error message telling it to would be instructing a schema violation.
|
||||
Omission still works at runtime; `null` is the form that is also contract-legal.
|
||||
- **Validate where the STORED value is in hand — the handler, not the controller.** §3d's rule that an
|
||||
*unchanged* bad value must still be accepted applies here in its sharpest form: these are whole-list
|
||||
replace PUTs, so rejecting a pre-existing empty set would make every *other* item in the list
|
||||
uneditable over a row the operator never touched. That comparison needs the stored row, which the
|
||||
controller does not have and the handler already loaded. Exemplar: `RecurrenceSetBounds`
|
||||
(`ErsatzTV.Application/Scheduling`), one validator called from both replace handlers — the same shape
|
||||
as `FFmpegProfileBounds`. The exemption is **per field**, not per row: a row grandfathered on
|
||||
`DaysOfWeek` still cannot newly empty `MonthsOfYear`.
|
||||
- **Derive the validated set from the list the handler actually writes.** In the alternate-schedule
|
||||
path that is `incoming`, which *excludes* the highest-`Index` catch-all — the handler discards that
|
||||
item's recurrence along with its date range, so an empty set there cannot make anything "never
|
||||
apply" and rejecting it would state a reason that is false for that item. Walking the same list the
|
||||
writes iterate is what keeps the check and its subject from drifting; do not re-derive "which item is
|
||||
the catch-all" in a second place (`api.put-replace-index-order`).
|
||||
|
||||
## 4. Artwork contract
|
||||
|
||||
API response DTOs return **rooted, directly-usable artwork URLs** — e.g. `/artwork/posters/...`,
|
||||
@@ -601,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).
|
||||
|
||||
@@ -891,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)
|
||||
|
||||
|
||||
+312
-68
@@ -41,6 +41,7 @@ Upstream's final release was **`v26.3.0`** (archived). Our line continues from t
|
||||
| `v26.12.0` | **`ErsatzTV.Mcp` MCP server** — read + cautious-write over `/api/v1`, `ERSATZTV_ALLOW_WRITES`-gated (#58). **External channel-logo URLs download + cache at save time** (#525), with the on-screen bug now rendered for external-URL logos (#502). HLS cold-start hardening: burst-read the first segments so start isn't `-readrate`-bound (#350) and floor QSV extra hardware frames so an unthrottled read can't exhaust the pool (#529); remote graphics-engine image fetches bounded — timeout, size cap, decode cap, redirects, pooling (#511). Decision-lifecycle tooling + parallel-orientation startup rewrite (#520/#521); CI `docker build` lane rebalance (#508). |
|
||||
| `v26.13.0` | **RuleBuilder maturation** — arbitrary-depth group nesting (#436), inline smart-query authoring in Channel Builder (#437), DB-sourced facet typeahead + relative-date operators + validation (#434/#435/#438), and an artist typeahead covering music-video/song credits with `album_artist` no longer 404ing (#578). **Per-channel On Now/Next transient overlay** (#74/#570) and **per-schedule clock-boundary padding** (#392); in-browser channel preview (#60); Auto-Tune per-source weight steppers + exclude/add-untagged (#440). Library-browse pickers now resolve by search instead of a 100-row window, closing several silent at-cap truncations (#644/#650/#651/#634). Correctness: one watermark resolver for all four attachment points, incl. `MiddleCenter` (#503/#510); QSV HDR tonemaps through OpenCL because `vpp_qsv=tonemap` is a silent no-op (#505); `LibraryFolder` unique index + concurrent-insert tolerance (#491); per-library music-video identity with soft trash (#496); Jellyfin Album/Track music-video projection (#177); metadata-collection dedup (#500); accented facet values via a registered Unicode fold on SQLite (#668); `WorkAheadSlots` atomic slot claim, never a negative count (#536/#539); on-demand guide rebuild on thaw (#68). Process/CI: the H10 review-verdict gate became a sha-bound **required** commit status and was hardened through its false-open chain (#622/#629/#632/#648/#649/#672/#698), the decision corpus split to one YAML-frontmatter record per file (#610/#620), and headless Playwright UI-E2E flows landed (#445/#533). Five dual-provider migrations. |
|
||||
| `v26.14.0` | **Live TV no longer starves on embedded bitmap subtitles** — `-readrate` paces an input off its *furthest-behind* stream, and a PGS/DVD subtitle read through the video's own `-i` is sparse enough to drag the whole process to **0.53x realtime** against the 1.0x a client consumes, draining the buffer until the channel stalls. Fixed with a capability-gated `-readrate_catchup` (ffmpeg 8.0+) on realtime inputs, keeping `-readrate` on the frame-producing path so the `ffmpeg.qsv-extra-hw-frames-floor` bound is untouched; measured 0.533x → 1.067x on QSV and software, with a 240s QSV soak clean of allocation errors (#726). Affects items carrying an embedded bitmap subtitle matching the channel's subtitle mode — 3,182 of 24,646 media versions on prod, and a property of the *item*, not the channel, which is why the stall presented as random. Process/CI: the H10 review-verdict gate's repair sentinel became a fixed point and its write is now fenced on the timeline retarget count, closing a raced-sentinel false-open (#706/#707/#711). **The decisions validator now cross-checks its dependency-free frontmatter parse against PyYAML** and reports both the truncating unquoted `` #`` and the scalar-closing bare apostrophe as errors, so a record whose `rule:` silently halves under PyYAML fails the local gate instead of CI (#674/#688) — the ceiling-calibration claim was also split so the suite pins what the derivation MEANS rather than live-corpus order statistics. Dependencies: CliWrap 3.10.4, JetBrains.ReSharper.GlobalTools 2025.3.5. |
|
||||
| `v26.15.0` | **`main` became PR-only, which is what makes `review-verdict/h10` load-bearing** — branch protection now carries `enable_push: false` *and* `block_admin_merge_override: true`, so a direct `git push origin HEAD:main` is refused at pre-receive for every account including a site admin, and an admin cannot force-merge past a missing or red required context (#743/#749). Gitea only evaluates `status_check_contexts` on the PR merge path, so before this the whole gate was skippable with no forgery needed. The gate was then hardened through the rest of its false-open chain: CI's registry credential scoped so a head-resolved workflow cannot forge the status (#697), a stray expression delimiter **in a comment** that killed the gate outright and is now banned on the release path itself by a `scan` job `build` depends on (#751/#767), a positive verdict inherited only from an allow-listed reviewer (#742), the PR-timeline `HEAD` alias fenced on the `pull_push` count (#803/#664), and both `/statuses/{sha}` reads paged to a validated terminator (#763). App-facing: **FFmpeg profile numeric fields are bounded with a 422 that names the bound and its consequence** instead of being silently rewritten, and readrate pacing is exposed as a profile field (#735 — one dual-provider migration, `Add_FFmpegProfile_ReadRatePacing`). **On Now / Next gets a boxed background and is on by default** (#732). Field-level progressive disclosure lands as a shared `FieldHelp` trigger + portalled panel, adopted on FFmpeg Profiles and documented as `docs/spa-conventions.md` §15 (#734). Auto-Tune proposal rows align on a grid (#721); `AddItemsDialog` resolves by search instead of windowing a whole media-library type, with stale-response and is-mounted guards (#685/#740). Correctness: a paged handler now counts **the same query it pages**, so a filtered page stops reporting the unfiltered total across playouts, rerun collections and six sibling handlers (#690/#758); nullable primitive collections are guarded at the read site rather than mutated on a tracked entity — `SongMetadata.Artists` in both search indexers (#701/#824) and the scheduling `DaysOfMonth`/`MonthsOfYear`/`DaysOfWeek` columns, where a runtime NULL reads as **unrestricted** (#823); SPA full-replace bodies are built as `Complete<T>` so an omitted optional DTO field is a type error (#807); and every `ErsatzTV.Mcp` tool is pinned to its OpenAPI contract, with `graphicsElementIds` and `padToNearestMinute` declared so an MCP channel update stops silently stripping the overlay (#754/#757). Process/CI: one rule for how a guard derives its population and one for the proof it ships with, both enforced (#774/#775) — populations come from the git index, never a filesystem walk (#806/#819), every `MUTATION` grade is executed each suite rather than asserted in prose (#790), and verification code is itself code under test (#796). Workflows declare their own per-job metadata and guard populations derive from it (#786/#789); all six declare `permissions:` (#748); `ci-image.yml` publishes from `main` only and the persisted checkout credential is gone (#744/#746/#835). Hooks report their own execution and the report is measured (#776), and `docs.no-session-narrative` was generalized to every durable artifact and the sites its sweep IDENTIFIED were remediated as judgements rather than by regex — no closure is claimed over a phrasing space (#784/#812). A ruff config is committed and enforced in CI (#780). Dependencies: Scriban.Signed 7.2.6. **Cut at `736649b3b`, not at `main`'s head**: `completeAnnotations.guard.test.ts` (merged in the very next commit) needs a git index the Dockerfile's `web-build` stage does not have, so every image build after `736649b3b` fails — including a tag build (#887). `736649b3b` is the newest commit whose `Build & push image (amd64)` actually ran green. The scheduling-recurrence fix #880 therefore slips to the next release. |
|
||||
|
||||
**Before cutting a release — sweep `docs/decisions.md` + `docs/decisions/`** (ersatztv#521, supersedes
|
||||
the ersatztv#303 H9 append-only ritual). Supersession/retirement is now a same-PR act (add the new
|
||||
@@ -100,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.
|
||||
@@ -169,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)
|
||||
|
||||
@@ -313,7 +333,9 @@ further back than N. Measured 2026-08-26: a 150-commit repo branched 130 back, `
|
||||
returns the correct file list. Under the old `|| true` that surfaced as a silent skip; unmasked it
|
||||
would have reddened `api-docs` and `Formatting` on any branch not rebased recently, sent
|
||||
`docs-reminder`'s parity nudge advisory-red, and left `decisions lifecycle` green with its
|
||||
diff-dependent checks silently skipped — so the depth came off with the mask. The same graft is why `:latest` images ship `InformationalVersion 0.0.0-<sha>` (ersatztv#836).
|
||||
diff-dependent checks silently skipped — so the depth came off with the mask. The same graft,
|
||||
reached through the detector script rather than a workflow step, is what stamped `:latest` images
|
||||
`InformationalVersion 0.0.0-<sha>` until ersatztv#836 — see below.
|
||||
|
||||
**Two masked fetches that stay masked, deliberately.** `scripts/ci-detect-docs-only.sh` and
|
||||
`scripts/ci-detect-already-validated.sh` both swallow a failed fetch — and should. In both, a failed
|
||||
@@ -323,14 +345,38 @@ runner time and nothing else, which is the opposite direction from the workflow
|
||||
the same masking bought a silently skipped check. Judge these by which way the failure falls, not by
|
||||
the `|| true`.
|
||||
|
||||
That clears their **masking** and nothing else. Their `--depth` is a separate question with a
|
||||
separate answer: `ci-detect-docs-only.sh`'s push-path `--depth=2` runs inside the `build` job's
|
||||
`fetch-depth: 0` checkout and grafts it shallow, which is why the `git describe` in the immediately
|
||||
following step returns nothing and `:latest` ships `InformationalVersion 0.0.0-<sha>` — measured on
|
||||
the live test container as `0.0.0-5a7a4ed7` (ersatztv#836). That depth is correct for the script's
|
||||
other three consumers — `test`, `migrations` and `functional-e2e` — whose checkouts really are
|
||||
shallow (`fetch-depth: 2`). `build` is the only one that checks out complete, which is why it is the
|
||||
only one that grafts.
|
||||
That clears their **masking** and nothing else. Their `--depth` was a separate question with a
|
||||
separate answer, and it is the one that shipped a defect (ersatztv#836).
|
||||
|
||||
**A depth is passed only to a checkout that is already shallow.** `ci-detect-docs-only.sh` routes
|
||||
both its fetches through `fetch_ref`, which asks `git rev-parse --is-shallow-repository` and drops
|
||||
the `--depth` on any answer but `true`. The script has **exactly four consumers** — `test`,
|
||||
`migrations` and `functional-e2e` at `fetch-depth: 2`, and `build` at `fetch-depth: 0`. Enumerate
|
||||
them with `grep -rn ci-detect-docs-only .gitea/`, not by looking for a step with `id: detect`:
|
||||
`api-docs` and `format` have one of those too, and it runs their own inline diff.
|
||||
|
||||
So `build` is the only complete-clone consumer, and the push arm is the only arm it takes. That is
|
||||
where the cost landed: its `--depth=2` grafted `build`'s complete clone, the `git describe --tags`
|
||||
in the immediately following step found no reachable tag, and a `|| echo v0.0.0` fallback that
|
||||
cannot fail turned that into a version. Every `:latest` image published from `main` carried
|
||||
`InformationalVersion 0.0.0-<sha>` instead of `26.x.y-<sha>` — read off the live test container as
|
||||
`0.0.0-5a7a4ed7` on 2026-08-26, six weeks after the depth landed with #416 on 2026-07-17, because
|
||||
nothing went red.
|
||||
|
||||
The PR arm's `--depth=200` had **no complete-clone consumer at all** (on `pull_request` only the
|
||||
three shallow jobs run this script), so it was inert rather than latently firing. It goes through
|
||||
the same helper anyway, because the clause is shared and the next `fetch-depth: 0` consumer of that
|
||||
arm would inherit the graft silently. Keeping the depth for the shallow three is likewise
|
||||
conservative rather than required: a depth-less fetch into a `--depth=2` clone leaves `.git/shallow`
|
||||
in place, still writes `FETCH_HEAD`, and still reports the parents the push arm reads.
|
||||
|
||||
Both halves are now held by something that executes. `scripts/tests/test_docs_only_detector_clone_depth.py`
|
||||
drives the real script over real `file://` clones and asserts the clone is still complete and
|
||||
`git describe` still resolves afterwards, with a negative control that requires the fixture to be
|
||||
able to graft at all — a plain path clone silently ignores `--depth`, which would make the whole
|
||||
file vacuous. And `Compute version and tags` no longer defaults: a failed `git describe` on the
|
||||
non-tag path fails the job rather than stamping `0.0.0`, so no `:latest` is published instead of a
|
||||
mislabelled one. The tag path never calls `describe`, so a release cut is unaffected either way.
|
||||
|
||||
### The last exemption, and how it closed
|
||||
|
||||
@@ -709,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
|
||||
@@ -874,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
|
||||
@@ -1121,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
|
||||
@@ -1228,11 +1283,15 @@ genuine pytest regression surface as precisely the red everyone is told to wave
|
||||
name keeps a real failure unambiguous.
|
||||
|
||||
**Why it runs unconditionally** rather than behind a `scripts/**` path filter: the suite's true
|
||||
input set spans more than one directory — `test_post_review_verdict.py` and
|
||||
input set spans AT LEAST three top-level directories. `test_post_review_verdict.py` and
|
||||
`test_merge_consent_exemption.py` execute the real `scripts/post-review-verdict.sh` and
|
||||
`.claude/hooks/pretooluse-merge-consent.sh` — so a `scripts/**` filter would silently miss a
|
||||
`.claude/hooks/**` edit. The reason is the input set, not the cost: the suite was ~10s when that was
|
||||
decided and is now ~4.5 min, and it would still be wrong to filter on `scripts/**`.
|
||||
`.claude/hooks/pretooluse-merge-consent.sh`, and since ersatztv#845 the former also READS
|
||||
`.gitea/workflows/review-verdict.yml`, because the writer derives the H10 allow-list from it and
|
||||
editing that literal changes the suite's outcome. So a `scripts/**` filter would silently miss a
|
||||
`.claude/hooks/**` or `.gitea/workflows/**` edit. The hedge is deliberate: the corpus tests read
|
||||
`docs/` too, so the real figure is higher, and an exact count here is the kind that goes stale.
|
||||
The reason is the input set, not the cost: the suite was ~10s when that was decided and is now
|
||||
minutes, and it would still be wrong to filter on `scripts/**`.
|
||||
|
||||
**It also lints (ersatztv#780).** Early in the job it installs a **pinned** `ruff==0.12.11` and runs
|
||||
`ruff check` and `ruff format --check` against the repo-root `ruff.toml`. Five things are deliberate:
|
||||
@@ -1399,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
|
||||
@@ -1563,8 +1628,9 @@ membership).
|
||||
falls through to re-derivation.
|
||||
- The **last-moment re-read** reads `ex_attributable`, because it asks the opposite question — "did a
|
||||
reviewer post a verdict while we were classifying". Narrowing it makes the job stop abstaining and
|
||||
post its exemption over the row, and the post-write repair does not cover that: it is skipped
|
||||
whenever the high-water mark could not be established. It additionally requires the
|
||||
post its exemption over the row, and the post-write repair would not cover that — it is skipped
|
||||
whenever the high-water mark could not be established, which since ersatztv#849 is also the case in
|
||||
which no exemption is posted at all. It additionally requires the
|
||||
state/creator/description triple to have *changed* since the first read, because the two calls no
|
||||
longer compute an identical predicate and "changed" can no longer be inferred from "fired".
|
||||
- The **post-write raced check** stays broad on `creator != null`. Not because narrowing it would let
|
||||
@@ -1586,12 +1652,45 @@ account to `H10_REVIEWERS` in `.gitea/workflows/review-verdict.yml`. (A separate
|
||||
annotation carrying the same three values was tried and withdrawn: its only delta was a severity whose
|
||||
rendering could not be probed, and it needed a guard of its own to stop contradicting the line above
|
||||
it.) `post-review-verdict.sh` posts
|
||||
with whatever account owns `ETV_GITEA_TOKEN`/`ETV_GITEA_BASICAUTH` and does **not** check that it is an
|
||||
allow-listed one (ersatztv#845). A changed writer credential does not fail loudly — it reports success,
|
||||
and each *subsequent* PR event re-derives the status: to `pending` on a PR that needs a verdict, or to
|
||||
an exemption **`success`** on a docs-only or Renovate-manifest one, which is the quieter and worse
|
||||
half. A verdict posted after the last event stands until the next one, so the symptom is a PR that
|
||||
keeps losing its verdict rather than an immediate refusal.
|
||||
with whatever account owns `ETV_GITEA_TOKEN`/`ETV_GITEA_BASICAUTH`, and since ersatztv#845 it
|
||||
**checks that it is an allow-listed one** — at the terminal that made the mistake, not one PR event
|
||||
later. It reads the status back after posting, identifies its own write by state and description, and
|
||||
refuses **before writing the comment** unless `.creator.login` is a member; a `creator: null`
|
||||
(Actions-token) status gets its own message, since there is no account to name. The allow-list is
|
||||
DERIVED from the `H10_REVIEWERS` literal in `review-verdict.yml` by `scripts/lib/h10-reviewers.sh`,
|
||||
not restated beside it, so there is one declaration rather than two plus a parity test.
|
||||
|
||||
**The membership requirement is `success`-only, mirroring the gate's own asymmetry.** An existing
|
||||
`failure` is inherited on ATTRIBUTABILITY alone, whichever account recorded it, so requiring
|
||||
membership for a `BLOCKED` verdict would refuse one the gate honours perfectly well — and would
|
||||
leave an off-list reviewer no supported way to record a rejection, since the comment would be
|
||||
withheld too. A negative verdict from any attributable account is written and commented normally.
|
||||
|
||||
What that changes is the *diagnostic*, not the safety direction: a non-allow-listed POSITIVE verdict
|
||||
was always a stall, never a fail-open. Before, a changed writer credential reported success and each
|
||||
*subsequent* PR event re-derived the status — to `pending` on a PR that needs a verdict, or to an
|
||||
exemption **`success`** on a docs-only or Renovate-manifest one, which is the quieter and worse
|
||||
half — so the symptom was a PR that kept losing its verdict rather than an immediate refusal. Now it
|
||||
is an immediate refusal naming the account and the allow-list.
|
||||
|
||||
**The residual, stated precisely, because "the `ask` half-state" describes only half of it.** The
|
||||
check runs after the POST, so a refused verdict leaves a `review-verdict/h10` **standing on the
|
||||
head** with no comment beside it. On the *hook* path that is the safe half-state
|
||||
`release.verdict-writes-status-before-comment` designates — nothing for condition (c) to classify,
|
||||
so it asks rather than granting. On the *server* path it is weaker: branch protection binds the
|
||||
context NAME and not its issuer, so a `success` posted by any account satisfies the required check
|
||||
until the gate re-derives it on the next PR event. **A refusal from this tool is not a reason to
|
||||
trust the green it leaves behind.** That is not a regression — it was equally true before #845, when
|
||||
the tool reported success and said nothing — and it is deliberately not repaired here, because a
|
||||
second corrective write is the sticky-sentinel mechanism ersatztv#849 is separately designing.
|
||||
|
||||
**One false ACCEPT, named because the direction matters.** The writer reads the workflow in the local
|
||||
checkout while the gate runs the base-resolved one, so on a branch that ADDS a reviewer the writer
|
||||
accepts an account the live gate will not yet inherit from — the #845 stall, reached through the
|
||||
check meant to catch it, on the one branch where someone is editing the allow-list. Accepted:
|
||||
closing it means a live remote read on the merge-consent write path, whose own read failure would
|
||||
have to resolve either to refusing every verdict or to passing. The window is one PR long and the
|
||||
residual is a stall, never an unreviewed merge.
|
||||
|
||||
This remains a *provenance* check, not an authentication one — an **allow-listed** collaborator's own
|
||||
write-scoped token can still POST a verdict they did not form, branch protection binds the context
|
||||
@@ -1626,20 +1725,34 @@ payload, which a retarget cannot rewrite, and `edited` is in `types:` so a retar
|
||||
`edited` gives **detection, not atomicity**: runs are not serialized, so a stale run could still post
|
||||
`success` after the reclassifying run posted `pending`.
|
||||
|
||||
**That residual is now fenced — NARROWED, not resolved (ersatztv#706; correction ersatztv#849).** The fence never re-counts *after* the POST, so a retarget landing between its final pre-write count and the write still yields a **permanent** forged green: the successor run consumes the `edited` event and exits on the existing status, and the stale run then posts last with nothing left to correct it. Runs are still not serialized — instead a run that was
|
||||
overtaken *declines to write*. The job counts `change_target_branch` events on the PR's issue timeline
|
||||
**That residual is now fenced — NARROWED, not resolved (ersatztv#706; correction ersatztv#849).** The pre-write fence does not re-count *after* the POST, so a retarget landing between its final pre-write count and the write used to yield a **permanent** forged green: the successor run consumes the `edited` event and exits on the existing status, and the stale run then posts last with nothing left to correct it. Since ersatztv#849 a SEPARATE post-POST re-count (below) makes that green transient rather than permanent. Runs are still not serialized — instead a run that was
|
||||
overtaken *declines to write its classification*. The job counts `change_target_branch` events on the PR's issue timeline
|
||||
at start and again immediately before its POST, and posts **nothing** if the count moved. The count is
|
||||
the key precisely because the branch *name* is ABA-vulnerable: `main → scratch → main` reads `main` at
|
||||
both ends, which is how the forged exemption was obtained in the first place. Abstaining never strands
|
||||
a PR, because every retarget fires `edited` — the event that makes one run abstain has already queued
|
||||
its successor.
|
||||
|
||||
If the count can't be established (unreadable timeline, paging that never reached a validated empty
|
||||
page), only the exemption `success` is withheld; `pending` still posts, since it blocks the merge
|
||||
If the count can't be established (unreadable timeline, an empty FIRST page, or paging that reached
|
||||
the 20-page cap on a NON-empty one — since #870 an empty page before the cap no longer ends the walk),
|
||||
only the exemption `success` is withheld; `pending` still posts, since it blocks the merge
|
||||
immediately and withholding it would strand ordinary PRs whenever the timeline is unreadable. That is
|
||||
the right trade but not a free one — a generic `pending` can mask a rejection landing in its own write
|
||||
window and be re-derived into an exemption `success` later (ersatztv#849). **If an exempt PR is
|
||||
unexpectedly missing its status after a retarget, this is why** — the job log names the counts.
|
||||
the right trade but not a free one — a generic `pending` still masks a rejection landing in its own
|
||||
write window. What made that durable is gone: since ersatztv#849 the post-write check runs after
|
||||
**every** write, so such a write is repaired to the sticky sentinel instead of being re-derived into
|
||||
an exemption by a later run. **If an exempt PR is unexpectedly missing its status after a retarget,
|
||||
this is why** — the job log names the counts.
|
||||
|
||||
**The count is also re-taken AFTER the POST, on the exemption path only** (ersatztv#849,
|
||||
`ci.verdict-unverified-write-sentinel`). The pre-write fence covers *writing while overtaken*; it never
|
||||
covered *being overtaken after writing*, which was the worse of the two — a retarget landing after the
|
||||
final pre-write count left a stale `success` with the `edited` event already consumed by a successor
|
||||
that short-circuited, so nothing remained to reclassify. It makes that green **transient rather than
|
||||
permanent**, not absent: the `success` is live between its POST and the repair, so branch protection
|
||||
or a scheduled auto-merge can still observe it. Closing that window needs a compare-and-set the API
|
||||
does not offer. The retarget axis only: a push after the POST
|
||||
moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with
|
||||
the sha unchanged.
|
||||
|
||||
Worth knowing before reaching for the obvious alternative: **a concurrency group does not work here**,
|
||||
measured rather than assumed. Gitea 1.25.4 auto-cancels superseded `push` runs on a branch, but *not*
|
||||
@@ -1668,16 +1781,70 @@ 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.
|
||||
|
||||
Separately, after posting an exemption `success` 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. **That check runs only for a `success` write**, so it does not extend to
|
||||
a generic `pending` write: that write is not verified, and a rejection it masks is re-derived green
|
||||
by a later run (ersatztv#849). The repair is
|
||||
`pending`, never a copy of the human's verdict, which would attribute a human decision to the job.
|
||||
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.
|
||||
|
||||
**Both `/statuses/{sha}` reads are PAGED** (ersatztv#763). The history is read twice — before the write
|
||||
for the high-water mark, after it for the race check — and `limit` clamps to `MAX_RESPONSE_ITEMS`
|
||||
(measured 50), so a single read of a busy head returned a partial list.
|
||||
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`
|
||||
cannot turn a rejection green — false, since a generic `pending` masks a rejection just as well and,
|
||||
carrying no marker, is re-derived into an exemption by the next run with the human's row now below
|
||||
THAT run's high-water mark. The repair is `pending`, never a copy of the human's verdict, which would
|
||||
attribute a human decision to the job.
|
||||
|
||||
**Which sentinel the repair writes is part of the answer.** There are two, and they are not
|
||||
interchangeable (`ci.verdict-unverified-write-sentinel`):
|
||||
|
||||
- `Human verdict raced this exemption write — re-post the verdict` **asserts** that a verdict existed
|
||||
and a write buried it. Only the arm that actually *counted* such a row may claim it, and only a
|
||||
human re-posting clears it.
|
||||
- `Status write could not be verified — re-post the verdict` states only what was established —
|
||||
that the write could not be checked. Every "could not check" arm writes this one: an unreadable or
|
||||
over-cap history, an impossible empty history, an unusable count, no high-water mark at all, a
|
||||
post-POST retarget, and an unreadable combined-status read. It is equally sticky, and additionally
|
||||
**reconcilable**: a later run pages `/statuses/{sha}` in full and either finds a verdict underneath
|
||||
it — upgrading to the repair sentinel — or finds none and clears it, so a transient API failure does
|
||||
not cost a head its exemption permanently.
|
||||
|
||||
**No high-water mark means the write becomes the sentinel** — every re-derivable state, not only the
|
||||
exemption — withheld before the POST rather than posted and repaired: the defect is known in advance,
|
||||
and publishing a green to take it back opens a window branch protection, and an already-scheduled
|
||||
auto-merge, can see. A generic `pending` is included because it is exactly what a later run
|
||||
re-derives; `$REPAIR_DESC` is not, being the stronger fact.
|
||||
|
||||
**Every path that cannot establish what the head carries REPLACES the unknown state** rather than
|
||||
merely declining to write: the combined read (retried once first), all four page-2 completeness
|
||||
refusals, a row or consumed field whose type the schema does not allow, the path-predicate failure,
|
||||
the fence branch that cannot trust its retarget count while holding a derived `success`, and the two
|
||||
OBSERVED-mutation arms when the head carries a row the run did not inherit. Declining protects a real verdict and leaves a *forged* one standing, which is what an
|
||||
off-list `success` is; the job went red on a status branch protection does not read, and the fence
|
||||
branch was reached only after the classification had DECLINED to inherit the very row it then left
|
||||
current. Nothing is destroyed — `/statuses/{sha}` keeps one row per POST, so the next run's
|
||||
reconciliation finds a masked verdict and tells the reviewer to re-post. Writing the sentinel and
|
||||
failing the job are separate decisions: the read refusals were already non-zero exits and stay red,
|
||||
the fence branch exited 0 and still does.
|
||||
|
||||
**Reconciliation needs a witness.** It may clear the sentinel only over a complete history that
|
||||
CONTAINS the sentinel's own row, matched by the `id` the combined read reported — degrading to a
|
||||
DESCRIPTION match, which identifies a row with the same text rather than that row, only where the
|
||||
server omits `id`. `ex_unverified` means the combined endpoint just returned that row
|
||||
and `/statuses/{sha}` keeps one row per POST, so a complete-but-empty history contradicts a write
|
||||
that demonstrably happened — and `page_statuses` accepts an empty page 1 as complete, which is what
|
||||
made the shape reachable. The cost lands where the replacement actually cost something: on an
|
||||
APPROVED PR, reconciliation finds the masked verdict and upgrades to the repair sentinel, so the
|
||||
recovery is "you are asked to re-post", not "it clears itself".
|
||||
|
||||
**Every `/statuses/{sha}` read is PAGED** (ersatztv#763). The history is read twice on an ordinary
|
||||
run — before the write for the high-water mark, after it for the race check — and a THIRD time on a
|
||||
head carrying the unverified sentinel, for the reconciliation (ersatztv#849). `limit` clamps to
|
||||
`MAX_RESPONSE_ITEMS` (measured 50), so a single read of a busy head returned a partial list.
|
||||
|
||||
**Be precise about what that cost** — ersatztv#763's framing of it is too strong. Under the server
|
||||
default (`created_unix DESC`) page 1 holds the *newest* rows and ids are monotonic with `created_at`,
|
||||
@@ -1695,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
|
||||
@@ -1772,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
|
||||
@@ -1898,14 +2079,28 @@ Two things kept it invisible, and both are worth generalising:
|
||||
fidelity, that claim is a test assertion and needs re-measuring like any other.
|
||||
|
||||
The type is now read as a value (`case` over `jq -r 'type'`) rather than through `jq -e`, whose
|
||||
exit-status semantics already bit this workflow once at jq 1.6, and both `null` and `[]` terminate the
|
||||
walk. The regression test is parameterised over both shapes because both are live on this server.
|
||||
An empty page — `null` OR `[]`, which take the SAME rule since ersatztv#803 — is accepted as
|
||||
exhaustion only from **page 2 on** — every real PR's first page carries events
|
||||
(spot-checked non-empty across #752/#753/#749/#739/#717; the counts are deliberately not recorded here
|
||||
because timelines grow and such figures go stale within days), so an empty first
|
||||
page is ANOMALOUS rather than EXHAUSTED, and the walk should not certify "no retarget happened" from a
|
||||
response it cannot explain.
|
||||
exit-status semantics already bit this workflow once at jq 1.6, and both `null` and `[]` are read as
|
||||
the same EMPTY shape. The regression test is parameterised over both because both are live on this
|
||||
server. An empty page — `null` OR `[]`, which take the SAME rule since ersatztv#803 — is **no longer
|
||||
accepted as exhaustion before the page cap** (ersatztv#870, 2026-08-30). Gitea pages this endpoint at the database level and
|
||||
filters afterwards, so a page whose 50 rows are all inline-code comments serializes as exactly the
|
||||
value the walk used to trust as the end of the list, while later pages still hold events — 50 comments,
|
||||
which a PR author can create on their own PR, bought silence on both axes. The walk now SKIPS an empty
|
||||
page before the cap, reads every page up to its 20-page cap, and trusts the counts only when the LAST
|
||||
page it read came back empty — the same rule the status walk above states for the CAP, though not for termination: that walk still returns on its first empty page, and whether it needs the same change is tracked at ersatztv#893 rather than assumed (measured 2026-08-30, `/statuses/{sha}` returns a TRUE `X-Total-Count`, which this endpoint does not). **That
|
||||
moves the inference to the cap rather than removing it, and the residual is real**: pages 1-19 cover
|
||||
offsets 0..949, so a timeline of more than 1000 rows whose offsets 950..999 are all filtered still
|
||||
returns an empty page 20, and the walk publishes a too-low count as trusted. Both re-reads share the
|
||||
blind spot, so this is the same defeat at about 10x the price — the 50-row filtered block is the
|
||||
same, but the timeline it must sit in grows from ~100 rows to over 1000, and on their own PR the
|
||||
padding is the attacker's to supply — not a closed hole. It is NOT the status walk's over-cap residual: `/statuses/{sha}` does not
|
||||
filter after paging, so only here can a page-20 response be empty while later pages hold rows. An empty
|
||||
FIRST page still ends the walk untrusted: every real PR's first page carries events (spot-checked
|
||||
non-empty across #752/#753/#749/#739/#717; the counts are deliberately not recorded here because
|
||||
timelines grow and such figures go stale within days), so an empty first page is ANOMALOUS rather than
|
||||
EXHAUSTED, and the walk should not certify "no retarget happened" from a response it cannot explain.
|
||||
`X-Total-Count` cannot shortcut this: on this endpoint it is the post-filter length of the PAGE, not a
|
||||
total (`?limit=1` returns 1 on a 14-row timeline), unlike `/activities/feeds`, which does return one.
|
||||
|
||||
**The same nil-slice shape bites `/commits/{sha}/status`** — a third instance. A head with no statuses yet returns
|
||||
`{"state":"pending","total_count":0,"statuses":null}` (measured on PR #739's head). `read_existing_verdict`
|
||||
@@ -2090,7 +2285,8 @@ like every other line in the file, so a branch that *deletes* it re-enables the
|
||||
What is closed is the **drive-by** case: an ordinary push of a legitimate `docker/ci` change
|
||||
publishing an image nobody asked for, with no deliberate act anywhere in the loop. Against someone
|
||||
who intends to run their own YAML this is not a boundary at all, and citing it as one is the mistake
|
||||
to avoid — see #853.
|
||||
to avoid — see #853, which settled its DISPATCH row; the `v*` tag-push and `pull_request:` rows are
|
||||
live in #885.
|
||||
|
||||
**What this does not close — beginning with this file's own filter, then sorted by REF CLASS
|
||||
rather than by which keyword a trigger carries.**
|
||||
@@ -2101,7 +2297,37 @@ that commit's `docker-build.yml` and can publish `ersatztv:prod` — no dispatch
|
||||
`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml` and `renovate.yml` all carry an
|
||||
unrestricted `workflow_dispatch`, which loads its definition from the selected ref. The filter here
|
||||
removes the *drive-by* route, where merely pushing a branch was enough; it is not a boundary against
|
||||
a malicious writer. The whole remaining class is tracked in #853.
|
||||
a malicious writer.
|
||||
|
||||
**That remaining class was probed and ACCEPTED in #853 — read this before proposing a fix for it.**
|
||||
Gitea 1.27.1 has no way to restrict `workflow_dispatch` by ref (the API takes `ref` as a required
|
||||
free-form string) and no protected-environment concept to gate a secret behind — zero of its 308
|
||||
documented API paths mention "environment", and the config file the running server names in its own
|
||||
`--config` sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`. More to the point,
|
||||
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. (`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
|
||||
@@ -2150,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
|
||||
@@ -2179,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
|
||||
@@ -2225,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
|
||||
@@ -2276,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,47 @@
|
||||
---
|
||||
key: api.absent-collection-means-unrestricted
|
||||
title: '2026-08-30 — An ABSENT request collection means unrestricted; an explicitly EMPTY one is rejected with a 422 (#880)'
|
||||
status: active
|
||||
since: '2026-08-30'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'A nullable collection on a write path carries TWO distinct requests and they get DIFFERENT answers. ABSENT (property missing, or explicit `null` — Newtonsoft maps both to null, so they are indistinguishable and are treated as ONE case) means the client expresses NO restriction and NORMALIZES to the PERMISSIVE value; EXPLICITLY EMPTY (`[]`) is a well-formed request whose outcome is meaningless for a conjunctive filter, and is REJECTED with a 422 naming the consequence rather than rewritten. Collapsing the two is what ersatztv#880 records: `?? []` on the three recurrence arrays of `PlayoutAlternateScheduleItemRequest` / `PlayoutTemplateItemRequest` returned HTTP 200 and stored a row that could NEVER apply on any date, because `AlternateScheduleSelector.GetScheduleForDate` treats DaysOfWeek/DaysOfMonth/MonthsOfYear CONJUNCTIVELY (a miss on any one `continue`s), so empty is the maximally RESTRICTIVE value, not a neutral one. The permissive value is not chosen freshly: it must be the SAME SYMBOL the read side substitutes for the same absence — here `AlternateScheduleSelector.All*()`, already used by the selector''s null guard and both `Mapper.ProjectToViewModel` overloads (`media.nullable-primitive-collection-mutation`), whose residual (3) this closes. Distinguishing absence from empty REQUIRES the property to be nullable (`List<T>?`), which in the `ErsatzTV` web project needs a per-file `#nullable enable` — the project has annotations off, and sibling request records already use that form. THE TWO HALVES LIVE IN DIFFERENT LAYERS AND THAT SPLIT IS THE DECISION: normalization sits in the request record''s `ToReplaceItem`, but REJECTION SITS IN THE HANDLER, in `RecurrenceSetBounds` (`ErsatzTV.Application/Scheduling`) called from BOTH replace handlers — the `FFmpegProfileBounds` shape. It CANNOT sit beside the normalization in the controller, because `api.ffmpeg-profile-numeric-bounds`'' rule that an UNCHANGED bad value must still be accepted binds here in its sharpest form: both PUT paths are WHOLE-LIST replaces, so rejecting a pre-existing empty set would make every OTHER item in the list uneditable over a row the operator never touched. That comparison needs the STORED row, which the controller does not have and the handler already loaded. The exemption is PER FIELD, not per row — a row grandfathered on `DaysOfWeek` still cannot newly empty `MonthsOfYear` — and a STORED NULL is NOT exempt, because null means unrestricted, so submitting `[]` against it is a new emptying. The validated set is DERIVED from the list the handler actually writes (`incoming`), which EXCLUDES the highest-`Index` catch-all: the handler discards that item''s recurrence along with its date range, so an empty set there cannot make anything "never apply" and rejecting it would state a reason that is FALSE for that item — do not re-derive "which item is the catch-all" in a second place (`api.put-replace-index-order`). Finally, the error message says SEND NULL, not "omit the property": making a C# property nullable does NOT make it optional in the generated schema — all three are still listed in `required` in `v1.json` with type `["null","array"]` — so a client generated from the published contract cannot omit them, and telling it to would instruct a schema violation. Omission still works at runtime; `null` is the form that is also contract-legal.'
|
||||
signals: 'omitted array means never applies · absent versus explicitly empty on a write path · `?? []` inverts a conjunctive filter · empty set is the most restrictive value not the neutral one · normalize absence to the same symbol the read side substitutes · `List<T>?` plus per-file `#nullable enable` in a project with annotations off · reject an empty recurrence with 422 naming the consequence · rejection needs the STORED row so it lives in the handler not the controller · unchanged stored empty is exempt per FIELD not per row · a stored NULL is not exempt because null means unrestricted · validate the list the handler writes so the catch-all is excluded by construction · nullable does not mean optional in the generated schema so say send null not omit · paths: `ErsatzTV.Application/Scheduling/RecurrenceSetBounds.cs`, `ErsatzTV/Controllers/Api/Requests/PlayoutAlternateScheduleItemRequest.cs`, `ErsatzTV/Controllers/Api/Requests/PlayoutTemplateItemRequest.cs`, `ErsatzTV.Application/Playouts/Commands/ReplacePlayoutAlternateScheduleItemsHandler.cs`, `ErsatzTV.Application/Scheduling/Commands/ReplacePlayoutTemplateItemsHandler.cs` · issues: #880, #823, #701'
|
||||
mechanics: 'Two halves, pinned in two files and mutation-proved SEPARATELY by EXECUTION against the real predecessor clause. NORMALIZATION is pinned in `ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs` by `Replace{AlternateSchedules,Templates}_Should_Normalize_Absent_Recurrence_To_Unrestricted` plus `ReplaceAlternateSchedules_Should_Preserve_An_Explicit_Recurrence_Selection` — the last is the complement, without which a fix that substituted `All*()` UNCONDITIONALLY would still pass. Proof: reverting the three `?? All*()` clauses to `?? []` in both request records reddens EXACTLY those three. REJECTION is pinned in `ErsatzTV.Tests/Application/Playouts/PlayoutHandlerTests.cs` against a real `InMemoryTvContext`, and the four cases are chosen to be mutually distinguishing: a newly-empty field on a NON-catch-all item is rejected; the same empty on the CATCH-ALL is ACCEPTED; an UNCHANGED stored empty is accepted; and the SAME stored row still REJECTS a different field newly emptied. Each rejection case also asserts the version did NOT bump, which is the anti-vacuity control — a 422 that had already mutated would be a different bug. One fixture trap is load-bearing and was measured, not reasoned about: the template handler loads `.Include(p => p.Templates).ThenInclude(t => t.Template)` and that navigation is REQUIRED, so a `PlayoutTemplate` seeded WITHOUT its `Template` row is joined OUT and never reaches `existing` — the stored row is then invisible, the exemption cannot match, and the test fails claiming the rule is broken when the fixture is. Seed the `TemplateGroup` and `Template` too.'
|
||||
---
|
||||
|
||||
**The empty set is not the neutral value, and that is the whole defect.** For a conjunctive filter,
|
||||
absence and emptiness sit at OPPOSITE ends: absence restricts nothing, empty admits nothing. `?? []`
|
||||
reads as a harmless "default to no filter" and is in fact "default to matching nothing" — the row is
|
||||
stored, the write returns 200, and the item silently never applies. Nothing logs it, because from the
|
||||
scheduler's point of view a non-matching item is ordinary. The SPA never triggered it (it sends full
|
||||
arrays and its generated type marks the field `null | Array<T>`, so the key is always present) and the
|
||||
MCP server exposes no such write tool, which is why this survived: it was reachable only by a direct
|
||||
API client, and only by OMITTING a field.
|
||||
|
||||
**Why the two cases must not be merged in either direction.** Normalizing `[]` to the permissive value
|
||||
too would make it impossible to say "no days" at all, and would re-collapse the distinction the
|
||||
nullable annotation was added to express. Rejecting absence instead would break every client that
|
||||
legitimately omits an optional field. The pair — normalize absence, reject empty — is the only
|
||||
assignment that leaves both requests expressible and neither one silent.
|
||||
|
||||
**The first implementation put the rejection in the controller, and an independent review was right to
|
||||
block it.** Validating the request there is cheaper and matches the sibling `ValidateDateRanges`, but
|
||||
it has no access to the stored row, so it rejected an unchanged legacy empty and — because these are
|
||||
whole-list replaces — would have made every sibling item unsaveable. It also validated EVERY item
|
||||
uniformly, including the catch-all whose recurrence is discarded, producing a 422 whose stated reason
|
||||
("the item would never apply") is false for exactly that item. Both disappear once the check moves to
|
||||
the handler and derives its population from `incoming`. The uniformity of `ValidateDateRanges` is
|
||||
precedent for a STYLE, not evidence that the behaviour is correct; that was the reasoning error.
|
||||
|
||||
**Prod was measured before choosing to reject rather than grandfather** (2026-08-30, jazz
|
||||
`/config/ersatztv.sqlite3`): `ProgramScheduleAlternate` and `PlayoutTemplate` both hold ZERO rows. That
|
||||
measurement is why the exemption could not be justified by pointing at live data — it had to be
|
||||
justified by the shape of a whole-list replace instead, which holds for any deployment. Do not cite
|
||||
the zero-row count as a reason to drop the exemption.
|
||||
|
||||
**The SPA can still produce an empty set** (`PlayoutScheduleEditors.tsx` toggles days individually,
|
||||
with no floor at one), so a user who unchecks every day now receives the 422 instead of silently
|
||||
saving a dead row. That is the intended outcome and it is a visible behaviour change; a client-side
|
||||
floor mirroring the server message would be the §3d "mirror the exemption in the client" follow-up if
|
||||
the error proves awkward in the form.
|
||||
@@ -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.'
|
||||
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
|
||||
@@ -172,8 +214,17 @@ still holds repo-write, so the property is "only a workflow visibly opting into
|
||||
verdict", not "no workflow can". Scoping still bounds what a job may DO, never whether attacker YAML
|
||||
RUNS. A
|
||||
collaborator's own token always can — and after #742 an ALLOW-LISTED collaborator's still is
|
||||
inherited, which is the residual the allow-list explicitly does not touch. `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).
|
||||
inherited, which is the residual the allow-list explicitly does not touch. **Which route is cheapest
|
||||
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 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.
|
||||
|
||||
@@ -5,9 +5,9 @@ status: active
|
||||
since: '2026-07-29'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'The three inputs the exemption decision rests on must each be bound to something the judged PR cannot mutate. (1) BASE — `scripts/pr-changed-files.sh` takes the expected base BRANCH as a REQUIRED 5th argument and re-reads it before and after paging, because `/pulls/{n}/files` diffs against the PR''s live base and retargeting moves the answer without moving the head sha; the workflow passes `github.event.pull_request.base.ref` from the `pull_request_target` payload, which a retarget cannot rewrite. (2) BOT EXEMPTION — an author match is necessary but never sufficient: `pull_request.user.login` is the PR''s immutable CREATOR while its head is not, so the exemption additionally requires EVERY changed path to be a dependency manifest (`Directory.Packages.props` or `.config/dotnet-tools.json`, and ONLY those — the npm manifests are excluded because `package.json` `scripts` are executed by CI). (3) INHERITED SUCCESS — the never-overwrite short-circuit fires for a `success` only when it is POSITIVELY identified as an APPROVED REVIEWER''s verdict for THIS base, meaning a `.creator.login` that is a member of the workflow''s `H10_REVIEWERS` allow-list (an existing `failure` takes the weaker ATTRIBUTABLE test and needs no membership, because inheriting a rejection can only withhold an exemption while re-deriving one can turn it green on an exempt PR) — non-null was too weak, since any account''s credential satisfies it and `RENOVATE_TOKEN` is one that cannot be scoped down (#742) — AND a `Review-verdict:` description AND, when that description records a base (`(base: …)`, `release.verdict-status-check`), a base matching the PR''s — tested by requiring the description to END with the exact literal `(base: <base>)` and to contain exactly ONE such marker, never by extracting a value (see below); a present-but-different base is rejected, an absent one is not, since verdicts predating that convention carry none; every other shape, including any unrecognised one, is re-derived rather than trusted. The bot and docs-only exemptions are evaluated as INDEPENDENT predicates and the decision made afterwards, never as an `elif` chain. `edited` is in the workflow''s `types:` so a retarget reclassifies — which gives DETECTION, not atomicity: status writes are not serialized, so a stale run can still post over a fresher one. That residual is now FENCED rather than merely tracked (NARROWED, not resolved — a retarget between the fence''s final count and the POST still leaves a PERMANENT forged green, #849) — the job refuses to write at all if the PR''s timeline retarget COUNT moved while it was classifying (`ci.verdict-write-retarget-fence`, #706) — leaving TWO windows, not one: the sub-round-trip window that no API without compare-and-set can close, AND the post-final-count/pre-POST window, which is not sub-round-trip and yields a PERMANENT green because the successor can consume the `edited` event and exit before the stale run posts last (#849). The PROTECTED path list additionally covers `CLAUDE.md` and `AGENTS.md` (#751) — they are not prose but the documents DEFINING the completion protocol, the merge-consent convention and the H10 rule, so protecting `.claude/` while the file specifying what it enforces stayed docs-only-exempt was the same self-exemption one directory over; driving the real classify body with a lone `CLAUDE.md` change produced an exemption `success`. `README.md` is deliberately not listed. It also covers `.codex/` (#711), which mirrors `.claude/hooks/` byte for byte including the merge-consent hook — latent while that directory is untracked, live the moment it is tracked; the list stays ENUMERATIVE rather than derived, because a derived rule would have to be evaluated against the very file list being classified. Reading the CURRENT status for input (3) must tolerate `statuses: null`: `GET /commits/{sha}/status` serialises a nil slice as `null`, not `[]`, on a head with no statuses yet, and an `array`-only gate made `read_existing_verdict` `exit 1` and post nothing at all (#751, `ci.workflow-run-body-no-expressions`) — `null` is accepted only when `total_count` is 0, so a body that merely lost its array is still refused. Path predicates are evaluated by COUNTING with `grep -c`, never `| grep -q` (SIGPIPE inversion) and never a here-string (temp-space failure) — see `ci.grep-q-pipefail-inversion`.'
|
||||
signals: 'verdict inherited from a bot token, RENOVATE_TOKEN posts a review verdict, who may post an h10 verdict, H10_REVIEWERS allow-list, forged review-verdict exemption, retarget race against the docs-only classifier, PR base changed mid-run, hijacked Renovate branch, bot exemption on a code change, machine-written success inherited as a verdict, status creator null vs user, never overwrite a human verdict, exemption chain skips docs-only for bots, why is my Renovate PR asking for a verdict, base ref binding on pr-changed-files.sh · paths: `.gitea/workflows/review-verdict.yml`, `scripts/pr-changed-files.sh`, `.claude/hooks/pretooluse-merge-consent.sh`, `scripts/tests/test_pr_changed_files.py` · issues: #849, #845, #742, #698, #697, #672, #663, #649, #632'
|
||||
mechanics: '`scripts/pr-changed-files.sh <owner> <repo> <pr> <expected-head-sha> <expected-base-ref>` (5 args; a 4-arg call exits 2); workflow env `BASE_REF: ${{ github.event.pull_request.base.ref }}`; `BOT_MANIFESTS` anchored allow-list; `H10_REVIEWERS="timothy"` declared as a literal in the classify body (a repo `vars:` entry or secret is settable without a PR); membership by exact POSIX `[ = ]` comparison over word-split entries, never an UNQUOTED right-hand side — `[[ $rv == $ex_creator ]]` makes it a pattern, while a quoted RHS under `[[ ]]` is literal (probed, bash 5.3); POSIX `[ = ]` cannot glob at all, which is why it is used and never `grep -xF` (an API-supplied creator containing a newline reads as several patterns); `read_existing_verdict` sets `ex_attributable` (creator non-null AND `^Review-verdict:` AND base OK) and derives `ex_human` from it by membership; the INHERITANCE short-circuit reads `ex_human`, the LAST-MOMENT RE-READ reads `ex_attributable` plus a change against the state/creator/description triple snapshotted at the first read, and the post-write raced `jq` stays broad on `creator != null`; `types: [opened, reopened, synchronize, ready_for_review, edited]`'
|
||||
rule: 'The three inputs the exemption decision rests on must each be bound to something the judged PR cannot mutate. (1) BASE — `scripts/pr-changed-files.sh` takes the expected base BRANCH as a REQUIRED 5th argument and re-reads it before and after paging, because `/pulls/{n}/files` diffs against the PR''s live base and retargeting moves the answer without moving the head sha; the workflow passes `github.event.pull_request.base.ref` from the `pull_request_target` payload, which a retarget cannot rewrite. (2) BOT EXEMPTION — an author match is necessary but never sufficient: `pull_request.user.login` is the PR''s immutable CREATOR while its head is not, so the exemption additionally requires EVERY changed path to be a dependency manifest (`Directory.Packages.props` or `.config/dotnet-tools.json`, and ONLY those — the npm manifests are excluded because `package.json` `scripts` are executed by CI). (3) INHERITED SUCCESS — the never-overwrite short-circuit fires for a `success` only when it is POSITIVELY identified as an APPROVED REVIEWER''s verdict for THIS base, meaning a `.creator.login` that is a member of the workflow''s `H10_REVIEWERS` allow-list (an existing `failure` takes the weaker ATTRIBUTABLE test and needs no membership, because inheriting a rejection can only withhold an exemption while re-deriving one can turn it green on an exempt PR) — non-null was too weak, since any account''s credential satisfies it and `RENOVATE_TOKEN` is one that cannot be scoped down (#742) — AND a `Review-verdict:` description AND, when that description records a base (`(base: …)`, `release.verdict-status-check`), a base matching the PR''s — tested by requiring the description to END with the exact literal `(base: <base>)` and to contain exactly ONE such marker, never by extracting a value (see below); a present-but-different base is rejected, an absent one is not, since verdicts predating that convention carry none; every other shape, including any unrecognised one, is re-derived rather than trusted. The bot and docs-only exemptions are evaluated as INDEPENDENT predicates and the decision made afterwards, never as an `elif` chain. `edited` is in the workflow''s `types:` so a retarget reclassifies — which gives DETECTION, not atomicity: status writes are not serialized, so a stale run can still post over a fresher one. That residual is now FENCED rather than merely tracked (NARROWED, not resolved — a retarget between the fence''s final count and the POST still leaves a PERMANENT forged green, #849) — the job refuses to write at all if the PR''s timeline retarget COUNT moved while it was classifying (`ci.verdict-write-retarget-fence`, #706) — leaving TWO windows, not one: the sub-round-trip window that no API without compare-and-set can close, AND the post-final-count/pre-POST window, which is not sub-round-trip and yields a PERMANENT green because the successor can consume the `edited` event and exit before the stale run posts last (#849). The PROTECTED path list additionally covers `CLAUDE.md` and `AGENTS.md` (#751) — they are not prose but the documents DEFINING the completion protocol, the merge-consent convention and the H10 rule, so protecting `.claude/` while the file specifying what it enforces stayed docs-only-exempt was the same self-exemption one directory over; driving the real classify body with a lone `CLAUDE.md` change produced an exemption `success`. `README.md` is deliberately not listed. It also covers `.codex/` (#711), which mirrors `.claude/hooks/` byte for byte including the merge-consent hook — latent while that directory is untracked, live the moment it is tracked; the list stays ENUMERATIVE rather than derived, because a derived rule would have to be evaluated against the very file list being classified. Reading the CURRENT status for input (3) must tolerate `statuses: null`: `GET /commits/{sha}/status` serialises a nil slice as `null`, not `[]`, on a head with no statuses yet, and an `array`-only gate made `read_existing_verdict` `exit 1` and post nothing at all (#751, `ci.workflow-run-body-no-expressions`) — `null` is accepted only when `total_count` is 0, so a body that merely lost its array is still refused. Path predicates are evaluated by COUNTING with `grep -c`, never `| grep -q` (SIGPIPE inversion) and never a here-string (temp-space failure) — see `ci.grep-q-pipefail-inversion`. The WRITER is bound to the same allow-list rather than trusted to match it (#845): `scripts/post-review-verdict.sh` reads its own status back, identifies that write by state and description, and refuses before writing the comment unless `.creator.login` is a member — so a writer credential that is not a reviewer fails at the terminal that made the mistake instead of stalling the PR one event later. The list is DERIVED from the literal in this workflow by `scripts/lib/h10-reviewers.sh`, never restated: one declaration, not two plus a parity test.'
|
||||
signals: 'verdict inherited from a bot token, RENOVATE_TOKEN posts a review verdict, who may post an h10 verdict, H10_REVIEWERS allow-list, forged review-verdict exemption, retarget race against the docs-only classifier, PR base changed mid-run, hijacked Renovate branch, bot exemption on a code change, machine-written success inherited as a verdict, status creator null vs user, never overwrite a human verdict, exemption chain skips docs-only for bots, why is my Renovate PR asking for a verdict, base ref binding on pr-changed-files.sh · paths: `.gitea/workflows/review-verdict.yml`, `scripts/pr-changed-files.sh`, `.claude/hooks/pretooluse-merge-consent.sh`, `scripts/tests/test_pr_changed_files.py`, `scripts/post-review-verdict.sh`, `scripts/lib/h10-reviewers.sh` · issues: #849, #845, #742, #698, #697, #672, #663, #649, #632'
|
||||
mechanics: '`scripts/pr-changed-files.sh <owner> <repo> <pr> <expected-head-sha> <expected-base-ref>` (5 args; a 4-arg call exits 2); workflow env `BASE_REF: ${{ github.event.pull_request.base.ref }}`; `BOT_MANIFESTS` anchored allow-list; `H10_REVIEWERS="timothy"` declared as a literal in the classify body (a repo `vars:` entry or secret is settable without a PR); `scripts/lib/h10-reviewers.sh` derives that same literal for the write side by an anchored single-assignment parse and refuses on 0 or >1 matches, on a non-plain-login entry, or on an empty list; membership by exact POSIX `[ = ]` comparison over word-split entries, never an UNQUOTED right-hand side — `[[ $rv == $ex_creator ]]` makes it a pattern, while a quoted RHS under `[[ ]]` is literal (probed, bash 5.3); POSIX `[ = ]` cannot glob at all, which is why it is used and never `grep -xF` (an API-supplied creator containing a newline reads as several patterns); `read_existing_verdict` sets `ex_attributable` (creator non-null AND `^Review-verdict:` AND base OK) and derives `ex_human` from it by membership; the INHERITANCE short-circuit reads `ex_human`, the LAST-MOMENT RE-READ reads `ex_attributable` plus a change against the state/creator/description triple snapshotted at the first read, and the post-write raced `jq` stays broad on `creator != null`; `types: [opened, reopened, synchronize, ready_for_review, edited]`'
|
||||
---
|
||||
|
||||
`ci.gate-trigger-base-resolved` stopped a PR supplying the gate's own *definition*. This closes the
|
||||
@@ -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.
|
||||
|
||||
@@ -38,7 +43,7 @@ than described as fixed. `edited` and re-derivation remain one fix — `edited`
|
||||
on the existing `success`; re-derivation alone never gets a second run — but together they are
|
||||
mitigation, not a guarantee.
|
||||
|
||||
**Narrowed 2026-08-03 (#706) — an earlier heading here said RESOLVED, corrected 2026-08-27 (#849): the fence never re-counts after its final pre-write read, so the post-final-count/pre-POST window remains, and is PERMANENT rather than transient. Worth recording that the narrowing finally came from somewhere else
|
||||
**Narrowed 2026-08-03 (#706) — an earlier heading here said RESOLVED, corrected 2026-08-27 (#849): the pre-write fence does not re-count after its final read, so the post-final-count/pre-POST window remains. It was PERMANENT rather than transient until #849 added a post-POST re-count (`ci.verdict-unverified-write-sentinel`), which makes it transient — the window between the POST and the repair — rather than closing it. Worth recording that the narrowing finally came from somewhere else
|
||||
entirely.** The missing piece was never ordering: `ci.verdict-write-retarget-fence` leaves the runs as
|
||||
unserialized as they ever were and instead makes a run that was overtaken decline to write, keyed on
|
||||
the timeline's monotonic retarget COUNT — the one signal the `main → scratch → main` ABA cannot make
|
||||
@@ -48,9 +53,11 @@ non-cancelling concurrency group — the fix this record's residual implied and
|
||||
measured doing nothing at all. The paragraph above stands as written; only its last sentence is
|
||||
overtaken, and the sub-round-trip window it describes survives, because Gitea's status API has no
|
||||
compare-and-set. **That is not the only window** — see the correction to
|
||||
`ci.verdict-write-retarget-fence` (2026-08-27, #849): the fence never re-counts after its final
|
||||
pre-write read, so a retarget landing between that read and the POST leaves a PERMANENT forged green
|
||||
rather than a transient one, whenever the successor run consumes the `edited` event and exits first.
|
||||
`ci.verdict-write-retarget-fence` (2026-08-27, #849): the PRE-WRITE fence does not re-count after its
|
||||
final read, so a retarget landing between that read and the POST left a PERMANENT forged green
|
||||
whenever the successor run consumed the `edited` event and exited first. #849 added a re-count AFTER
|
||||
the POST, so the writing run now withdraws its own stale `success`; what remains is the observable
|
||||
window between the POST and that repair, which is transient rather than permanent.
|
||||
|
||||
**Route 2 — a bot ACCOUNT does not attribute the CODE.** `pull_request.user.login` is the PR's
|
||||
immutable *creator*; its head is not. Push application code onto an open Renovate branch and the PR is
|
||||
@@ -214,27 +221,91 @@ the post-write history was unreadable. Re-review found both wanting, on grounds
|
||||
reviewer posting inside the write window, for a stall that needs only the read failure, is not
|
||||
obviously the safe direction, and it was described as a "one-run cost" when it is not.
|
||||
|
||||
So the whole of post-write verification stays in #849, where it can be designed once: every write
|
||||
verified when a mark exists, a sticky sentinel when it does not, and a CHAINED test feeding run N's
|
||||
real output into run N+1. Two lines inside a provenance change was the wrong size for it — the attempt
|
||||
is recorded because the next reader will otherwise re-attempt it the same way.
|
||||
So the whole of post-write verification went to #849, where it was designed once and landed on
|
||||
2026-08-29 as `ci.verdict-unverified-write-sentinel`: every write verified when a mark exists, a
|
||||
sticky **and reconcilable** sentinel when it does not, and a CHAINED test feeding run N's real output
|
||||
into run N+1. Two lines inside a provenance change was the wrong size for it — the attempt is recorded
|
||||
because the next reader will otherwise re-attempt it the same way, and because the withdrawal is what
|
||||
established that "sticky" alone is not enough.
|
||||
|
||||
**A coupling this record does NOT assert, tracked as #845 instead.** `post-review-verdict.sh` posts with
|
||||
whatever account owns `ETV_GITEA_TOKEN`/`ETV_GITEA_BASICAUTH` and never checks whose it is. If that
|
||||
ever stops being an allow-listed account, every verdict silently stops being inheritable and each
|
||||
later event re-derives it — to `pending` on a PR that needs a verdict, or to an exemption `success` on
|
||||
a docs-only or Renovate-manifest one, which is the quieter and worse half. Either way the verdict is
|
||||
gone, and the only diagnostic is a line in a job log.
|
||||
Correct today — verified live 2026-08-26, reproduce with `GET /api/v1/user` (login `timothy`) and
|
||||
`GET /api/v1/admin/users?limit=50` (exactly `renovate`, `timothy`) — but nothing keeps it correct
|
||||
(#845). The job DOES name the creator and the allow-list whenever it declines to inherit — but in the
|
||||
WORKFLOW RUN, while the mistake is made at the terminal running `post-review-verdict.sh`, which
|
||||
reports success either way. That gap is what #845 is about, and it is why a louder annotation was not
|
||||
the answer: an earlier draft added a `::warning::` alongside that line, and it was withdrawn after
|
||||
producing three defects in three rounds (a message that contradicted itself for an allow-listed
|
||||
creator, an unescaped API value interpolated into a workflow command, and a test asserting its absence
|
||||
on a path that exits two guards earlier and so could never fail). The signal has to reach the WRITER,
|
||||
not be repeated more loudly to the reader.
|
||||
**One residual named here was closed there too.** An UNREADABLE combined-status read used to leave a
|
||||
pre-existing off-list `success` standing: `read_existing_verdict` exited without replacing it, the job
|
||||
went red, and this workflow's own job status is not a required check, so branch protection still saw
|
||||
the green — the exact status this record exists to revoke, surviving a read failure. That read is now
|
||||
retried once and, on a persistent failure, the unknown state is REPLACED with the unverified sentinel.
|
||||
|
||||
**The writer/gate coupling, ASSERTED at the writer since #845.** `post-review-verdict.sh` posts with
|
||||
whatever account owns `ETV_GITEA_TOKEN`/`ETV_GITEA_BASICAUTH`. It used to never ask whose that was, and
|
||||
the two values are coupled: the day the writer credential stops being an allow-listed account, every
|
||||
verdict silently stops being inheritable and each later event re-derives it — to `pending` on a PR that
|
||||
needs a verdict, or to an exemption `success` on a docs-only or Renovate-manifest one, which is the
|
||||
quieter and worse half. Either way the verdict is gone. It now READS THE STATUS BACK after posting it,
|
||||
identifies its own write by state and description, and refuses — before the comment, so the surviving
|
||||
half-state is the `ask` one — unless `.creator.login` is on the allow-list. A `creator: null`
|
||||
(Actions-token) status is refused with its own message, because there is no account to name and "add
|
||||
them to the allow-list" would be the wrong advice.
|
||||
|
||||
The writer mirrors the state asymmetry above rather than simplifying it: membership is required for a
|
||||
`success` only. A `failure` is written and commented normally from any attributable account, because
|
||||
that is exactly what this record already says the gate does — requiring membership there would refuse
|
||||
a verdict the gate honours, and withhold the comment that is the only supported way to record a
|
||||
rejection. It also reads `.status` and NOT `.state`, with no fallback, because the gate reads
|
||||
`.status`: tolerating a shape the gate cannot read would make the writer report success on a verdict
|
||||
that gets re-derived, which is #845 recreated by the check meant to prevent it.
|
||||
|
||||
The allow-list is not restated on the writer side. `scripts/lib/h10-reviewers.sh` DERIVES it from the
|
||||
`H10_REVIEWERS` literal in this workflow, so there is one declaration rather than two held together by
|
||||
a parity test — the shape `testing.guard-derives-population-from-source` records six failed rounds
|
||||
against. It is a parse rather than a shared declaration both sides source, because the gate runs
|
||||
against a checkout of the PR BASE sha: a PR whose base predates such a file would not have it, and a
|
||||
missing `source` under `set -euo pipefail` kills the job, which posts no `review-verdict/h10` at all
|
||||
and blocks every merge including the repair (`block_admin_merge_override: true`). A fallback list for
|
||||
that case would reintroduce the second copy. So the derivation runs on the side that can fail safely
|
||||
and the body of this workflow is untouched. The parse admits exactly one anchored assignment or
|
||||
refuses; `test_pr_changed_files.py::test_the_H10_REVIEWERS_list_is_glob_free_and_non_empty` reads the
|
||||
same literal with the same anchors OVER THE SAME POPULATION — the whole file — so a shape change
|
||||
reddens CI on the PR that makes it. The population is part of the rule, not an implementation
|
||||
detail: that guard used to scope itself to the classify step's `run:` body, and a second anchored
|
||||
assignment in any other `run:` block then left it green while making the writer refuse every verdict.
|
||||
Measured before it was aligned: the guard green, and about half the writer suite red. What was
|
||||
missing is worth stating precisely, because the obvious phrasing is wrong twice over. Those failures
|
||||
DO carry the library's refusal, which names the count and the variable. And alignment ADDS a named
|
||||
guard failure rather than replacing them. What was missing is a failure POINTING AT THE WORKFLOW,
|
||||
since the reds surface under unrelated test names and the cause had to be inferred from a stack of
|
||||
them. No figure is given for how many name it, and the trap is worth recording so one is not added
|
||||
back: a `grep -c` over pytest output counts LINES rather than failures and `--tb=line` prints each
|
||||
matching failure twice, so such a count overstates, while raising the traceback detail moves it the
|
||||
other way by making more failures show the refusal text. Any figure is therefore a property of the
|
||||
pytest flags that produced it rather than of the code, and reproducing one means parsing per-failure
|
||||
sections instead of grepping lines. Such an edit reaching `main` WOULD be a repo-wide merge outage — no verdict could be
|
||||
posted and `review-verdict/h10` is required — but the guard is not the only thing standing in the
|
||||
way, and saying so would overstate it: the writer suite reddens too, in the ADVISORY `script-tests`
|
||||
job (the required contexts, measured 2026-08-30, are `Build & test`, `EF migration integrity` and
|
||||
`review-verdict/h10`). What alignment buys is ONE named guard failure pointing at the workflow, ALONGSIDE the half-suite of unrelated
|
||||
failures. The route by which the outage is actually reachable is the snapshot boundary below — a
|
||||
verdict posted from a checkout predating the edit — which is why the two paragraphs belong together.
|
||||
|
||||
**The one false ACCEPT this leaves, named with its direction.** The writer reads the workflow in the
|
||||
LOCAL checkout; the gate runs the base-resolved one. On a branch that ADDS a reviewer — the likely
|
||||
edit — the writer accepts that account while the live gate will not yet inherit from it, which is
|
||||
the #845 stall reached through the check meant to catch it. Accepted rather than closed: a live
|
||||
remote read of the base workflow puts a network dependency on the merge-consent write path, where a
|
||||
read failure must resolve either to a refusal (a new way to block every verdict) or to a pass (the
|
||||
hole, reopened). The window is one PR long, the person making the edit is the one who knows the list
|
||||
is in flux, and the residual is a STALL, never a merge of unreviewed code.
|
||||
|
||||
The allow-list was `timothy` and the instance held exactly `renovate` and `timothy` when this was
|
||||
measured on 2026-08-26 (`GET /api/v1/user`, `GET /api/v1/admin/users?limit=50`). That measurement is
|
||||
no longer what keeps the coupling correct, which is the point of the change.
|
||||
|
||||
**Why the signal had to reach the WRITER.** The job already names the creator and the allow-list
|
||||
whenever it declines to inherit — but in the WORKFLOW RUN, while the mistake is made at the terminal
|
||||
running `post-review-verdict.sh`, which reported success either way. An earlier draft tried to fix
|
||||
that by shouting louder at the reader: a `::warning::` alongside that line, withdrawn after producing
|
||||
three defects in three rounds (a message that contradicted itself for an allow-listed creator, an
|
||||
unescaped API value interpolated into a workflow command, and a test asserting its absence on a path
|
||||
that exits two guards earlier and so could never fail). Recorded so it is not re-attempted: more
|
||||
volume in the run was never the missing piece.
|
||||
|
||||
**What #742 does NOT close, restated because the allow-list invites the wrong reading.** An
|
||||
allow-listed collaborator's own write-scoped token can still POST a verdict nobody formed. Branch
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
key: ci.fetch-depth-never-grafts-a-complete-clone
|
||||
title: '2026-08-29 — A CI fetch passes --depth only to a checkout that is already shallow (#836)'
|
||||
status: active
|
||||
since: '2026-08-29'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'Any `git fetch` in CI tooling passes `--depth` only when `git rev-parse --is-shallow-repository` already answers `true`, and passes none on any other answer including an unreadable one. `git fetch --depth=N` GRAFTS a complete clone shallow: it writes `.git/shallow` and cuts history at N even though every object is already present, so a depth chosen for one consumer silently breaks every other consumer that checks out `fetch-depth: 0`. A shared script whose consumers disagree on checkout depth must ask the repository rather than assume. And whatever the fetch feeds — a version, a diff, a base revision — must be ASSERTED rather than defaulted: a fallback that cannot fail converts the graft into a plausible wrong answer that never reddens.'
|
||||
signals: 'git fetch --depth, .git/shallow, grafted clone, git describe finds no tag, InformationalVersion 0.0.0, fetch-depth 0 vs fetch-depth 2 · paths: `scripts/ci-detect-docs-only.sh`, `.gitea/workflows/docker-build.yml` · issues: #836, #746'
|
||||
mechanics: 'docs/ci-cd.md → "A depth is passed only to a checkout that is already shallow"; guard `scripts/tests/test_docs_only_detector_clone_depth.py`, a MUTATION-graded row in `docs/guard-inventory.md`'
|
||||
---
|
||||
|
||||
`scripts/ci-detect-docs-only.sh` has exactly four consumers in `docker-build.yml`, and they do not
|
||||
agree on checkout depth: `test`, `migrations` and `functional-e2e` check out `fetch-depth: 2`, and
|
||||
`build` checks out `fetch-depth: 0`. The script carried a `--depth` chosen for the first group and
|
||||
applied it to all of them. **`build` is the only complete-clone consumer.** `api-docs` and `format`
|
||||
also check out `fetch-depth: 0` and also have a step with `id: detect`, but that step runs their own
|
||||
inline diff, not this script. **Enumerate the consumers with `grep -rn ci-detect-docs-only .gitea/`,
|
||||
never by looking for a step with `id: detect`** — that is the trap, and it points at the wrong two
|
||||
jobs. The grep returns five hits: the four `run:` call sites plus `docker-build.yml`'s own header
|
||||
comment.
|
||||
|
||||
The harm is not theoretical. The push arm is the only arm `build` ever FETCHES on — it also takes
|
||||
the tag early-exit on a release cut and the `workflow_dispatch` arm, neither of which fetches — and
|
||||
that arm grafted `build`'s complete clone on every push to `main`;
|
||||
the very next step, `Compute version and tags`, runs `git describe --tags --abbrev=0`, which then
|
||||
found no reachable tag; and its `|| echo v0.0.0` turned that into a version string. Every `:latest`
|
||||
image published from `main` carried `InformationalVersion 0.0.0-<sha>` instead of `26.x.y-<sha>`
|
||||
from 2026-07-17, when #416 introduced the depth, until this change. Nothing went red at any point —
|
||||
the defect was found by reading the string out of a running container.
|
||||
|
||||
**Why the depth is kept for the shallow group, stated as measured rather than as load-bearing.** A
|
||||
depth-less fetch into a `--depth=2` clone leaves `.git/shallow` in place, still writes `FETCH_HEAD`,
|
||||
and still lets `git rev-list --parents -n1 HEAD` report the parents the push arm reads. So dropping
|
||||
the depth outright would not have broken those three jobs either. It is retained because it is what
|
||||
they were given and nothing argues for widening their fetch — not because removing it was shown to
|
||||
break anything.
|
||||
|
||||
**The PR arm's `--depth=200` had no complete-clone consumer at all.** On `pull_request` the only
|
||||
jobs running this script are the three shallow ones, so that depth was inert rather than latently
|
||||
firing. It is fixed in the same change anyway, because the clause is shared: the next
|
||||
`fetch-depth: 0` consumer added to that arm would inherit the graft silently, and rediscovering it
|
||||
would cost what #836 cost (`process.fix-the-boundary-not-the-site`). That is a guard against a
|
||||
future shape, and grading it as a reproduction of a shipped one would overstate it.
|
||||
|
||||
**Why any answer but `true` passes no depth.** There is more than one such answer, and enumerating
|
||||
them is the point: `git rev-parse` fails outright where there is no readable repository, and on a
|
||||
git older than 2.15 the flag is unrecognised and rev-parse ECHOES IT BACK verbatim with status 0
|
||||
(verified on 2.55 — an unknown `--is-...` flag prints itself and exits 0). Neither is `true`, both
|
||||
take the no-depth branch, and in neither could a depth have helped: the base stays unresolvable and
|
||||
every arm falls through to the full matrix, which is the safe direction for this script. Defaulting
|
||||
the other way would re-graft on exactly the paths nobody can observe.
|
||||
|
||||
**The second half is the loud failure, and it is not decoration.** Removing the graft removes
|
||||
today's cause; the fallback is what made any cause invisible. `Compute version and tags` now fails
|
||||
the job when `git describe` fails on the non-tag path. No `:latest` at all is visible and
|
||||
recoverable; a mislabelled one is neither, and `:latest` is what `ersatztv-test` tracks. The tag
|
||||
path never calls `describe`, so a release cut is unaffected — which is also why `:prod` and
|
||||
`:<version>` images were correctly versioned throughout.
|
||||
|
||||
**Fixture note for anyone testing this.** `git clone /path` and `git fetch /path` use the local
|
||||
transport, which ignores `--depth` outright. A reproduction built on a plain path never grafts, so
|
||||
every assertion holds and the test proves nothing; use `file://`. The guard carries a negative
|
||||
control that performs the raw depth fetch and requires the graft to happen, so that if this ever
|
||||
stops being true it is reported rather than silently making the rest vacuous.
|
||||
|
||||
Related: `ci.docs-only-detect-shallow-safe` covers the other half of this script's shallow
|
||||
behaviour — which revision it diffs against, and why it is `FETCH_HEAD` with a two-dot diff.
|
||||
@@ -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
@@ -34,9 +34,14 @@ sibling job carries an ignore-rule. Same question, opposite answers, for stated
|
||||
|
||||
**Unconditional, not path-filtered.** The suite's real input set spans more than `scripts/`:
|
||||
`test_post_review_verdict.py` and `test_merge_consent_exemption.py` execute the actual
|
||||
`scripts/post-review-verdict.sh` and `.claude/hooks/pretooluse-merge-consent.sh`. A `scripts/**`
|
||||
filter would silently miss a `.claude/hooks/**` edit — and at ~10s a filter buys nothing but a
|
||||
drift surface. Default checkout depth is sufficient: every `git` call in the suite runs against a
|
||||
`scripts/post-review-verdict.sh` and `.claude/hooks/pretooluse-merge-consent.sh`, and since #845 the
|
||||
former also READS `.gitea/workflows/review-verdict.yml` — the verdict writer derives the H10
|
||||
allow-list from it, so editing that literal changes the suite's outcome. A `scripts/**` filter would
|
||||
silently miss a `.claude/hooks/**` or `.gitea/workflows/**` edit. The enumeration is a lower bound
|
||||
rather than a census, deliberately: the corpus tests read `docs/` too, and an exact list here is the
|
||||
kind that goes stale — which it did, this way, before #845 extended it. The reason is the INPUT SET,
|
||||
not the cost: a filter was cheap to skip when the suite ran in seconds and is still the wrong axis
|
||||
now that it runs in minutes. Default checkout depth is sufficient: every `git` call in the suite runs against a
|
||||
temp repo it creates itself, never this repository's history.
|
||||
|
||||
**Verified by measurement, not a green tick** (the #631 Done-when, and `ci.docs-only-detect-shallow-safe`'s
|
||||
|
||||
@@ -5,8 +5,8 @@ status: active
|
||||
since: '2026-08-27'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'A `push` trigger reachable from any ref other than `main` — that is `branches:` AND `tags:`, judged by the ref class it admits and never by which keyword is present — executes ref-supplied YAML, because Gitea resolves a `push` workflow''s definition from the pushed ref — so `ci-image.yml` is `branches: [main]`, and publishing a toolchain image from a feature branch is a deliberate `workflow_dispatch` on that branch rather than a side effect of pushing. Be precise about what a `branches:` filter buys: it is loaded from the pushed ref like the rest of the file, so a branch that deletes it re-enables the route — this removes the DRIVE-BY case, and is not a boundary against a writer who intends to run their own YAML. The self-referencing trigger path `.gitea/workflows/ci-image.yml` came out of BOTH its own `paths:` and `ci-image-pin`''s `expected` in the same change — a DECIDED tradeoff, not a necessity: keeping it is workable via the branch dispatch, but prices every edit to that file, comments included, at a ~2GB publish plus a five-pin bump, redone after every rebase. The cost is stated, not assumed away — a change to HOW the image is built that lives only in `ci-image.yml` no longer republishes, and the ONLY remedy is to make it alongside a `docker/ci/**` edit: publishing after the merge and then pinning cannot work, because `expected` is the last `docker/ci` commit and would reject that pin. This closes the push route INTO THIS FILE, not the class: `docker-build.yml` remains reachable from an arbitrary ref by a `v*` tag push and by `pull_request`, and four workflows carry an unrestricted `workflow_dispatch` (#853).'
|
||||
signals: 'unfiltered push trigger, branches filter on push, attacker YAML on a runner, feature branch publishes an image, self-referencing workflow trigger, ci-image-pin expected goes stale, publish-then-pin two-step, toolchain image recovery after a rebase, workflow_dispatch on a branch · paths: `.gitea/workflows/ci-image.yml`, `.gitea/workflows/pr-checks.yml`, `.gitea/workflows/docker-build.yml`, `docker/ci/Dockerfile`, `docs/ci-cd.md`, `docs/decisions/records/ci/actions-credential-scoping.md`, `scripts/e2e-ui.sh` · issues: #744, #853, #697, #746, #835, #445, #390, #594, #597'
|
||||
rule: 'A `push` trigger reachable from any ref other than `main` — that is `branches:` AND `tags:`, judged by the ref class it admits and never by which keyword is present — executes ref-supplied YAML, because Gitea resolves a `push` workflow''s definition from the pushed ref — so `ci-image.yml` is `branches: [main]`, and publishing a toolchain image from a feature branch is a deliberate `workflow_dispatch` on that branch rather than a side effect of pushing. Be precise about what a `branches:` filter buys: it is loaded from the pushed ref like the rest of the file, so a branch that deletes it re-enables the route — this removes the DRIVE-BY case, and is not a boundary against a writer who intends to run their own YAML. The self-referencing trigger path `.gitea/workflows/ci-image.yml` came out of BOTH its own `paths:` and `ci-image-pin`''s `expected` in the same change — a DECIDED tradeoff, not a necessity: keeping it is workable via the branch dispatch, but prices every edit to that file, comments included, at a ~2GB publish plus a five-pin bump, redone after every rebase. The cost is stated, not assumed away — a change to HOW the image is built that lives only in `ci-image.yml` no longer republishes, and the ONLY remedy is to make it alongside a `docker/ci/**` edit: publishing after the merge and then pinning cannot work, because `expected` is the last `docker/ci` commit and would reject that pin. This closes the push route INTO THIS FILE, not the class: `docker-build.yml` remains reachable from an arbitrary ref by a `v*` tag push and by `pull_request`, and four workflows carry an unrestricted `workflow_dispatch`. The DISPATCH THIRD of that is settled — #853 probed it and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`): no ref restriction exists at Gitea 1.27.1, and restricting it would close nothing anyway, because the head-resolved `pull_request:` route runs attacker-authored YAML that reaches every secret in the store. The `v*` tag push and `pull_request:` rows are NOT settled and remain open in #885. Do not re-derive any of this.'
|
||||
signals: 'unfiltered push trigger, branches filter on push, attacker YAML on a runner, feature branch publishes an image, self-referencing workflow trigger, ci-image-pin expected goes stale, publish-then-pin two-step, toolchain image recovery after a rebase, workflow_dispatch on a branch · paths: `.gitea/workflows/ci-image.yml`, `.gitea/workflows/pr-checks.yml`, `.gitea/workflows/docker-build.yml`, `docker/ci/Dockerfile`, `docs/ci-cd.md`, `docs/decisions/records/ci/actions-credential-scoping.md`, `scripts/e2e-ui.sh` · issues: #744, #853, #885, #697, #746, #835, #445, #390, #594, #597'
|
||||
mechanics: '`docs/ci-cd.md` -> "CI toolchain image" -> "Publishing from a branch is a dispatch, not a push"; branch-dispatch probe 2026-08-27 (run 2340, published `ersatztv-ci:43b1e45`)'
|
||||
---
|
||||
|
||||
@@ -66,7 +66,14 @@ mechanics: '`docs/ci-cd.md` -> "CI toolchain image" -> "Publishing from a branch
|
||||
`ersatztv:prod` with no dispatch at all. On top of that, four workflows carry an unrestricted
|
||||
`workflow_dispatch`. What #744 changed is that none of this is any longer a side effect of an
|
||||
ordinary branch push. Read it as protection against an accidental or drive-by push, **not** as a
|
||||
boundary against a malicious or compromised writer; the whole remaining class is tracked in #853.
|
||||
boundary against a malicious or compromised writer. That remaining class was worked in #853 and
|
||||
**accepted rather than fixed** — `ci.workflow-dispatch-ref-unrestricted` carries the probe and the
|
||||
reasoning: Gitea 1.27.1 has no ref restriction for `workflow_dispatch` and no protected-environment
|
||||
concept, and restricting dispatch would change nothing, because `docker-build.yml`'s head-resolved
|
||||
`pull_request:` route runs attacker-authored YAML, which reaches every secret in the store — so it
|
||||
covers `renovate.yml`'s `RENOVATE_TOKEN` too, without dispatching `renovate.yml` at all. **Only the
|
||||
dispatch third is settled**: the `v*` tag push and the `pull_request:` route itself stay open in
|
||||
#885.
|
||||
- **The persist-credentials exemption was downstream of this, and closed with it.** `ci-image.yml`'s
|
||||
checkout was the one left without `persist-credentials: false` (#746), for exactly the two mechanical
|
||||
reasons above. Both gone, the flag went on in the same change, which is what let #835 ship its guard
|
||||
|
||||
@@ -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).
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,90 @@
|
||||
---
|
||||
key: ci.workflow-dispatch-ref-unrestricted
|
||||
title: '2026-08-30 — `workflow_dispatch` cannot be ref-restricted at Gitea 1.27.1, and restricting it would close nothing while `docker-build.yml` keeps `pull_request:` (#853)'
|
||||
status: active
|
||||
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, 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'
|
||||
---
|
||||
|
||||
- **The two capability questions the issue named were probed to a negative, and the negatives are the
|
||||
durable part of this record.** There is no ref restriction for `workflow_dispatch` at 1.27.1: the
|
||||
endpoint takes `ref` as a required free-form string and no repo, org or admin endpoint scopes which
|
||||
refs may be selected. There is no protected-environment concept to gate a secret behind — not a
|
||||
missing endpoint but a missing *feature*, evidenced by zero of 308 documented paths mentioning
|
||||
"environment" and by secrets existing only at org, repo and user scope. THREE surfaces were swept,
|
||||
because "the obvious call 404s" is not evidence of absence: the REST API, the config file the running
|
||||
server names in its own `--config`, and the `gitea` CLI. **The web UI was not swept, and that is a
|
||||
real limit on this evidence rather than a formality** — a Gitea Actions control can exist with NO API
|
||||
surface at all, which `ci.actions-credential-scoping` records for the owner-level default: it 404s on
|
||||
the API and was nonetheless set, verified and reverted. That record does NOT name the surface used —
|
||||
a non-API one is an INFERENCE by elimination here, since `app.ini` sets only two `[actions]` keys and
|
||||
the CLI exposes only `generate-runner-token`. It is left open because the
|
||||
decision does not rest on it — what carries the conclusion is the secret-scope model (org/repo/user
|
||||
only, no per-ref gate) plus the fact that restricting dispatch would close nothing regardless. Sweep
|
||||
the UI before citing this record as proof that no such control CAN exist. **Check the CLI's
|
||||
exit code, not its match count** — run as root the binary refuses to start and exits 1, so grepping
|
||||
its empty help output "finds nothing" for entirely the wrong reason; run as the service user it does
|
||||
list an `actions` command, whose sole subcommand is `generate-runner-token`. Re-probe on the VERSION
|
||||
rather than the date: `stale-after` is a calendar field, but what invalidates all of this is an
|
||||
upgrade past 1.27.1, which can land months earlier.
|
||||
- **Do not close this class with "anyone with repository write can already do worse."** That argument
|
||||
was the issue's own leading option and it is the one to reject, for two reasons. It is unfalsifiable
|
||||
as stated, so it licenses accepting anything; and it skips the question that actually decides the
|
||||
matter, which is whether dispatch is the *cheapest* route to the credential. It is not.
|
||||
- **`pull_request:` is the cheaper route, and it is the finding that settles the decision.**
|
||||
`docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR head, so the run
|
||||
executes **attacker-authored YAML** — and that, not the specific jobs, is the whole argument: such
|
||||
YAML can name any secret in the repo store, not only the ones the committed workflows reference.
|
||||
`ci.gate-trigger-base-resolved` states it directly — "any PR-added workflow can reference
|
||||
`RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store". That step is INFERRED from the
|
||||
repo-scoped secret model plus that record, not measured here: measuring it prints a live credential
|
||||
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.
|
||||
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
|
||||
in the same head-supplied file and an attacker edits them out.
|
||||
- **The write set is two accounts, and the asymmetry between them is worth stating once.** They are
|
||||
`timothy` (owner, site admin) and `renovate` (collaborator, `permission: write`, `is_admin: false`).
|
||||
Against the admin, no control administered by that same admin is a boundary — that is a property of
|
||||
the trust model, not a defect. Against the bot it is different, and the difference is easy to
|
||||
misread: considerable machinery bounds what Renovate can get MERGED (`review-verdict/h10` as a
|
||||
required context, and the exemption that auto-passes a Renovate PR only when every changed path is a
|
||||
dependency manifest AND none is under any of the six protected directories `.claude/`, `.codex/`,
|
||||
`.gitea/`, `.husky/`, `scripts/`, `docker/ci/` — #698, #742, #845; `ci.exemption-provenance` has the
|
||||
authoritative form). None of that machinery bounds
|
||||
what Renovate can RUN. A reader of `ci.exemption-provenance` could reasonably infer the bot is
|
||||
tightly constrained; on the run path it is not. Measured rather than inferred: on a matched probe
|
||||
against the same endpoint and payload, an authenticated non-writer short-circuits at authorization
|
||||
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 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
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,79 @@
|
||||
---
|
||||
key: process.hook-resolves-inputs-from-repo-root
|
||||
title: '2026-08-30 — a hook resolves every path it sources or executes from `$repo_root`, never from an environment variable (#858)'
|
||||
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 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. 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
|
||||
roots, in one file. #787 bound the guard-scope freshness checker and its snapshot to `$repo_root` and
|
||||
wrote the reasoning beside them; the H10 verdict classifier, added at #629, kept
|
||||
`${CLAUDE_PROJECT_DIR:-.}`; and the sourced fire-log library kept `${CLAUDE_PROJECT_DIR:-<self>}`.
|
||||
|
||||
**Two resolutions of the same question inside one file is the state most likely to be "tidied" toward
|
||||
the weaker one.** That is why this is a record rather than a second inline comment: a reader
|
||||
reconciling the inconsistency has even odds of reconciling it the wrong way, and the comment that
|
||||
would have stopped them is attached to the other site.
|
||||
|
||||
**The exemption this record tried to grant, and why it does not exist.** The first draft drew the
|
||||
boundary at "does its CONTENT change the decision" and placed `ETV_HOOK_FIRE_LIB` outside it as
|
||||
telemetry. Cold review refuted that by running it: a decoy `hook-fire-log.sh` in an env-var-named tree
|
||||
printing an `allow` decision and exiting 0 took the merge decision, 500 lines before the gate. The
|
||||
line is `. `-SOURCED — sourcing is execution, and a file's stated purpose grants no protection at all.
|
||||
The lesson generalises past this variable: **classify a path by how it is CONSUMED, never by what it
|
||||
is called.**
|
||||
|
||||
**Name the real threat, or the rule gets argued away.** "An env var is a security input" invites the
|
||||
correct objection that `$CLAUDE_PROJECT_DIR` already selects the hook itself, so an attacker who
|
||||
controls it has won already — and an argument refutable on its face gets the rule reverted. The
|
||||
defensible claim is narrower and holds: the harness launcher and husky are DIFFERENT launchers.
|
||||
Claude PreToolUse hooks are started as `$CLAUDE_PROJECT_DIR/.claude/hooks/…`, so there the two roots
|
||||
agree by construction and binding to `$repo_root` is consistency rather than repair. Husky starts the
|
||||
prepush hooks as `./.claude/hooks/…` from the pushed tree, wholly independent of the variable —
|
||||
measured 2026-08-30, a push from one worktree with the variable naming another sources the other
|
||||
tree's code into a gate that can block or allow the push. That is ordinary, non-adversarial, and
|
||||
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 — 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,43 @@
|
||||
---
|
||||
key: scan.mediaserver-statistics-from-server
|
||||
title: 2026-10-21 — A media-server scanner reads a version's statistics from the server, never from the library-items projection (#924)
|
||||
status: active
|
||||
since: '2026-10-21'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'Every media-server scanner writes a head `MediaVersion`''s statistics (streams, width/height, aspect, frame rate) from what the server reports per item — `GetMediaServerStatistics` (Jellyfin/Emby playback info) or the metadata call that carries them (`ServerReturnsStatisticsWithMetadata`, Plex) — and NEVER writes the library-items projection''s version, whose `Streams` list is empty. When the server cannot supply them, nothing is written. In `MediaServerMusicVideoLibraryScanner` the hook is `abstract`, not a virtual returning `None`.'
|
||||
signals: 'zero MediaStream rows, Width/Height 0, Sequence contains no matching element, SelectVideoStream, playback info, GetPlaybackInfo, projection Streams = [], deep scan to heal, music videos will not play · paths: `MediaServerMusicVideoLibraryScanner.UpdateStatistics`, `JellyfinMusicVideoLibraryScanner.GetMediaServerStatistics`, `MediaServerMovieLibraryScanner.UpdateStatistics`, `IMetadataRepository.UpdateStatistics`, `FFmpegStreamSelector.SelectVideoStream` · issues: #924, #496'
|
||||
mechanics: '`JellyfinMusicVideoLibraryScannerTests` (`ScanLibrary_Should_Store_PlaybackInfo_Statistics_For_A_New_MusicVideo`, `..._Deep_Scan_Should_Read_Statistics_For_An_Unchanged_MusicVideo_Without_Streams`, `..._Should_Read_Statistics_For_A_Returning_Unavailable_MusicVideo_Without_Streams`, `..._Should_Keep_Stored_Statistics_When_PlaybackInfo_Fails_On_Rescan`, `..._Should_Refresh_Statistics_And_Projection_Chapters_When_The_Etag_Changes`, against the real `MetadataRepository`); `FFmpegStreamSelectorTests.SelectVideoStream`'
|
||||
---
|
||||
|
||||
The Jellyfin and Emby library-items projections build every movie, episode and music-video version with
|
||||
`Streams = []` and no dimensions; only the per-item playback info carries them. The movie and TV scanners fetch
|
||||
statistics when the item is new, changed, deep-scanned or has no stored streams. The music-video scanner wrote
|
||||
the projection's version instead, so every Jellyfin music video was stored without streams and
|
||||
`FFmpegStreamSelector.SelectVideoStream` threw on play. Measured on prod 2026-10-21 (#924): the Jellyfin "Music Videos" library (id 16), 1520 of
|
||||
1520 music-video versions with zero `MediaStream` rows (movies 0, episodes 4).
|
||||
|
||||
- **Writing the projection is worse than writing nothing.** `IMetadataRepository.UpdateStatistics` reconciles
|
||||
the stored streams against the version it is handed, so an empty stream list REMOVES every stream an existing
|
||||
row has. A failed statistics read therefore leaves the row untouched, matching the movie scanner.
|
||||
- **The refresh condition is the movie scanner's**: `deepScan || IsAdded || etag changed || no stored streams`.
|
||||
For music videos `IsAdded` is implied by the empty-streams arm while the projection carries no streams; it is
|
||||
kept so the two conditions read the same.
|
||||
- **`abstract`, not virtual-`None`.** The movie base defaults `GetMediaServerStatistics` to `None`; Jellyfin and
|
||||
Emby override it, and Plex takes statistics from its metadata call (`ServerReturnsStatisticsWithMetadata`).
|
||||
Music videos have one implementation, and a `None` default is the defect itself, so a future Emby/Plex
|
||||
music-video scanner must answer the question to compile.
|
||||
- **Chapters come from the incoming projection**, not from the stored item as in
|
||||
`JellyfinMovieLibraryScanner`: `JellyfinMusicVideoRepository.GetByItemId` does not eager-load chapters, so the
|
||||
stored item has none to write.
|
||||
- **Healing stored rows takes a deep scan.** A regular scan skips an item whose etag is unchanged before the
|
||||
refresh condition is reached, so rows already stored without streams stay that way until their library is
|
||||
deep-scanned or the server changes the item. On a live instance that scan is a channel operation
|
||||
(media-management), not part of shipping the fix.
|
||||
- **The player names the gap.** `SelectVideoStream` on a version with no video stream fails with the version id,
|
||||
its file and the remediation (rescan; deep scan for a media-server library) instead of `First()`'s bare
|
||||
`Sequence contains no matching element`.
|
||||
- **Known residual, shared with movies:** when the server cannot answer, the item's etag is still recorded, so a
|
||||
transient playback-info failure on a new or changed item leaves it without streams until the next deep scan or
|
||||
server-side change. Not changed here: skipping the etag write would retry the item on every scan, and the same
|
||||
trade-off already holds for movies and episodes.
|
||||
@@ -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.
|
||||
@@ -7,7 +7,7 @@ supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'Any path that writes a WHOLE entity or a WHOLE child collection — a PUT-replace handler, a hand-built request object, a test comparer standing in for one — derives its field list from the authoritative type and asserts SET EQUALITY against it, rather than enumerating the fields by hand. A hand-written list is correct on the day it is written and structurally unable to report the day it stops being: the field that drifts is the one nobody wrote a line for, so no amount of care in the existing lines can reach it. The failure is silent by construction — a full replace with a field omitted returns HTTP 200 and destroys that field''s value (#754 drifted from a 28-property DTO by one and cleared it; the symptom arrived hours later as missing pixels). SECOND CLAUSE, separable from the first: where a replaced child row carries state keyed to its identity — progression, ordering, an enumerator position — the handler RECONCILES BY ID rather than delete-and-reinsert, because reinsertion silently resets state a client never asked to touch (#252: a schedule PUT reset fill-group progression; #500: a dedup fix became permanent data loss because the add filter and the remove filter used different keys, so the two halves must agree on the key). Delete-and-reinsert is acceptable ONLY where no such state exists, and that emptiness is a fact about today''s schema that a later feature can silently invalidate — so record it where the handler is, dated, rather than leaving it to be re-derived. The canonical worked example is `ToolCatalogTests.Every_Write_Tool_Should_Declare_Exactly_Its_OpenApi_Request_Body_Fields`, which reads the accepted fields from the generated OpenAPI document and compares both directions. ON THE SPA SIDE the same rule is enforced by the TYPE SYSTEM rather than by a test: a full-replace body is built as `Complete<T>` (`web/src/api/completeRequest.ts`), a mapped type that makes every member of a generated request type required, so a builder that omits one fails `npm run typecheck`. Annotate BOTH the API-wrapper parameter (so later callers inherit it) AND each construction site including every `.map` callback return type, because the excess-property check that catches a PHANTOM field fires only on a fresh literal in a contextually typed position and a generic `.map` callback is not one. Do not infer from "most builders already typecheck" that the gap is closed: a member is omittable exactly when it is absent from the schema `required` array in the ASP.NET-produced OpenAPI document, and two live cases (#807) sat unchecked inside a large majority of checked ones.'
|
||||
signals: 'full replace asserts its field list · hand-maintained mirror drifts by one field · reconcile by id not delete and reinsert · 200 and the field is gone · add filter and remove filter must share a key · fill-group progression reset by a PUT · derive the comparer from the DTO · a lossless round-trip test that is itself a hand-copied list · anti-vacuity PIN not a floor on a reflective walk · stale exemption must still name a real property · a complete comparer over a hand-written fixture · universal negative in a record is a trap · the SPA builds a full-replace body as `Complete<T>` · a member is omittable only when it is absent from the OpenAPI `required` array · excess-property checking does not fire inside a generic `.map` callback · a comment warning about a silent reset is not a check · `weight` · `qsvPreferNativeDecoder` · paths: `ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemResponseRoundTripTests.cs`, `ErsatzTV.Mcp.Tests/ToolCatalogTests.cs`, `ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs`, `web/src/api/completeRequest.ts`, `web/src/api/completeRequest.guard.test.ts` · issues: #807, #779, #773, #757, #754, #500, #252'
|
||||
mechanics: 'Detector G of `docs/defect-shapes-773.md` §4. Enforced per-site by a reflective comparison over the DTO plus a written-down count pin, not by a repo-wide check — see the record body for why a global one is not proposed. The exemption set is empty today; the machinery guarding exemptions remains for the first one that earns its place.'
|
||||
mechanics: 'Detector G of `docs/defect-shapes-773.md` §4. Enforced per-site by a reflective comparison over the DTO plus a written-down count pin, not by a repo-wide check — see the record body for why a global one is not proposed. The exemption set is empty today; the machinery guarding exemptions remains for the first one that earns its place. ON THE SPA SIDE the same rule is carried by the type system (`Complete<T>`, `spa.full-replace-complete` in `docs/spa-conventions.md` §4b), and since ersatztv#820 its APPLICATION is checked rather than assumed: `web/src/api/completeAnnotations.guard.test.ts` derives the droppable schemas from the generated `v1.d.ts` and the annotations from the SPA AST, asserts set equality between the droppable SCHEMAS and a reviewed disposition table, requires a PRODUCTION annotation naming each schema DISPOSITIONED as needing one — three of the thirteen that can drop a member, the other ten being creates, triggers, read models and two MUST-NOT-ANNOTATE rows, each ruled on individually. A test-file annotation does not discharge that: `*.test.*`, `*.spec.*` and the one setup file named in `vite.config.ts` are excluded, and the setup path is pinned so a rename reddens. NOT covered, stated rather than implied — a SECOND `setupFiles` entry, which vitest would load every run; deriving that list would need another virtual-module plugin and is not proportionate to a three-site coordinated edit. The guard also forbids an annotation on the schemas where annotating is a bug, cross-checks its disposition values against this file''s table from the Python side, and is per-SCHEMA rather than per-site or per-wrapper — a wrapper-level rule was built and REMOVED before merge after four review rounds each defeated it. It also rejects a `Complete<X>` over a hand-written mirror rather than a generated alias. Before it, `scripts/tests/test_optional_request_members.py`''s COVERED disposition ASSERTED the annotation existed and nothing verified it. Residual: the per-CONSTRUCTION-SITE population is not derived (sites in code, #777), so the phantom direction still rests on the §4b site rule and on review.'
|
||||
---
|
||||
|
||||
**This is `testing.guard-derives-population-from-source` applied to a write path, and it is worth its
|
||||
@@ -160,11 +160,16 @@ in two places: at the full-replace API-wrapper boundary, so later callers inheri
|
||||
it exists, and at each construction site, which is what keeps TypeScript's excess-property check
|
||||
(the *phantom* direction) alive.
|
||||
|
||||
That is the rule, not a claim about how much of the tree currently follows it. Nothing enforces the
|
||||
second half — #820 — so any sentence here asserting present coverage would be falsifiable by one
|
||||
`tsc` run and would go stale on the next screen anyone adds. Two wrappers annotated under #807
|
||||
initially shipped with unannotated construction sites for exactly that reason. To find out what is
|
||||
actually annotated, read the code; do not read a count here.
|
||||
That is the rule, not a claim about how much of the tree currently follows it. The two halves are
|
||||
enforced UNEQUALLY, and the split is the thing to know. Since #820 the SCHEMA half is checked:
|
||||
`web/src/api/completeAnnotations.guard.test.ts` derives the droppable schemas from the generated
|
||||
types and the annotations from the SPA AST, and fails when a schema that can drop a member is named
|
||||
by no PRODUCTION `Complete<…>`. It is per-SCHEMA: it catches the annotations being deleted, not one
|
||||
being moved off an API wrapper onto some other production file. The
|
||||
CONSTRUCTION-SITE half is still enforced by review only — that population is sites in code (#777).
|
||||
So a count of annotated sites here would still be falsifiable by one `tsc` run and would go stale on
|
||||
the next screen anyone adds. To find out what is actually annotated, read the code; do not read a
|
||||
count here.
|
||||
|
||||
**Why the second half of the rule is the one that gets skipped.** The wrapper annotation catches a
|
||||
MISSING member anywhere. The PHANTOM direction — a field the schema does not accept — relies on
|
||||
@@ -247,11 +252,26 @@ contingency is now MONITORED — the derived test above reddens the moment one o
|
||||
optional member — instead of being assumed. Annotating the rest is cheap and should happen when
|
||||
each is next touched.
|
||||
|
||||
**Residue, named rather than implied closed.** `Complete<T>` proves its own semantics but NOT that
|
||||
it is applied: reverting one screen to its pre-#807 form leaves the guard green, because the
|
||||
population of construction sites is derived from nothing. The repo already owns the tool for that
|
||||
— `web/src/api/pageSizeScan.ts` (TypeScript compiler API) plus a registry cross-checked in both
|
||||
directions — and building it is tracked in #820. Until then this convention is enforced by review,
|
||||
which is the weaker thing this record exists to warn about. Separately: `Complete<T>` is shallow,
|
||||
so a new nested item request type needs its own annotation; and requiring a field to be *named* is
|
||||
not the same as requiring it to be *populated* correctly.
|
||||
**Residue, named rather than implied closed.** #820 built the half that had an enumerable
|
||||
population: `web/src/api/completeAnnotations.guard.test.ts` derives the droppable schemas from the
|
||||
generated `v1.d.ts` and the `Complete<…>` annotations from the SPA AST, so deleting every production
|
||||
annotation for a schema dispositioned as needing one now reddens. It found one live hole on landing: two
|
||||
`playouts.ts` request types were hand-written mirrors SHADOWING generated schemas of the same name,
|
||||
so their `Complete<>` was checking a local copy rather than the contract.
|
||||
|
||||
What is NOT closed: the per-CONSTRUCTION-SITE population, which is sites in code (#777), so the
|
||||
PHANTOM direction still rests on review, as does the wrapper half of the rule. A test-file annotation
|
||||
cannot discharge the obligation in any spelling vitest COLLECTS (`*.test.*`, `*.spec.*`) nor from the
|
||||
one setup file `vite.config.ts` names, whose path is pinned — but a SECOND `setupFiles` entry would,
|
||||
and that direction is open —
|
||||
but a screen that builds a body inline without annotating its `.map` callback is still only caught by
|
||||
a reader, and so is an annotation moved off a wrapper. A guard that DID enforce the wrapper half was
|
||||
built for #820 and removed before merge: four review rounds each found a fresh way past it, the last
|
||||
being an `export function` to `export const` refactor invisible to both the AST scan and its
|
||||
cross-check at once. Five defects from one mechanism, so the mechanism went.
|
||||
Separately: `Complete<T>` is shallow, so a new nested item request type needs its own annotation. A
|
||||
DEEP variant was probed under #820 and works against the real generated types, but it was rejected:
|
||||
it cannot be applied blanket-wise, because `UpdateChannelRequest.logo` reaches
|
||||
`ArtworkContentTypeModel`, where annotating would force a caller to fabricate server-computed
|
||||
values — and it closes only the missing direction, not the phantom one. Finally, requiring a field
|
||||
to be *named* is not the same as requiring it to be *populated* correctly.
|
||||
|
||||
@@ -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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user