Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fcb5e9b28 | ||
|
|
884ac8a7e9 | ||
|
|
9a5d34e888 | ||
|
|
20b117dabf | ||
|
|
e133c11fde | ||
|
|
951dae26a9 | ||
|
|
46ec532745 | ||
|
|
edd8d3d9c9 | ||
|
|
40b3747434 | ||
|
|
2bdb6c44e4 | ||
|
|
9881d1ff81 | ||
|
|
3aed43c6de | ||
|
|
f822e4737c | ||
|
|
6af65ba5c5 | ||
|
|
6d80343320 | ||
|
|
b91707b707 | ||
|
|
691a7acc77 | ||
|
|
08e95f9ec1 | ||
|
|
b91939e5c4 | ||
|
|
e298bb291e | ||
|
|
d7647b6104 | ||
|
|
7be42654fe | ||
|
|
d7725c274c | ||
|
|
b6bf94f129 | ||
|
|
4be3f247d8 | ||
|
|
28ce8c4dfe | ||
|
|
e46e2cfe68 | ||
|
|
3a6174c953 |
@@ -12,6 +12,38 @@ set -uo pipefail
|
||||
[ "${ETV_SKIP_REBASE_CHECK:-}" = "1" ] && exit 0
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
|
||||
|
||||
# Tag-only push exemption (ersatztv#719): the release cut tags a commit on main while the local
|
||||
# branch sits 1 commit behind origin/main, so H11 blocked EVERY release -- and its "rebase first"
|
||||
# advice did not even apply, since no branch was being pushed. A tag push cannot revert anyone's
|
||||
# merged work, which is the failure mode H11 exists to prevent, so skip the freshness check when
|
||||
# EVERY ref being pushed is under refs/tags/. (See #719 for the observed flow.)
|
||||
#
|
||||
# Read pushed refs from stdin: git feeds pre-push hooks one line per ref, "<local ref> <local sha>
|
||||
# <remote ref> <remote sha>" (.husky/pre-push forwards the lines it already captured). Ignore blank
|
||||
# lines. VACUOUS-TRUTH GUARD: "all refs are tags" is trivially true when there are zero ref lines
|
||||
# (hook run manually, stdin not forwarded, etc.) -- that would silently disable H11 for every push.
|
||||
# Require at least one parsed ref line before granting the exemption; with zero lines, fall through
|
||||
# to the existing branch-freshness check below (current behavior preserved).
|
||||
#
|
||||
# `[ -t 0 ] ||` so an interactive run does not hang waiting on a terminal: this script had no stdin
|
||||
# reader before #719, and its own docs call "run by hand" a supported case. A TTY yields no ref
|
||||
# lines, which is exactly the zero-line fall-through.
|
||||
_h11_refs_seen=0
|
||||
_h11_all_tags=1
|
||||
[ -t 0 ] || while IFS=' ' read -r _h11_local_ref _h11_local_sha _h11_remote_ref _h11_remote_sha \
|
||||
|| [ -n "${_h11_local_ref:-}" ]; do # `|| [ -n ... ]` also processes a final line with no trailing newline
|
||||
[ -z "${_h11_local_ref:-}" ] && continue
|
||||
_h11_refs_seen=1
|
||||
case "${_h11_remote_ref:-}" in
|
||||
refs/tags/*) ;;
|
||||
*) _h11_all_tags=0 ;;
|
||||
esac
|
||||
_h11_local_ref=''
|
||||
done
|
||||
if [ "$_h11_refs_seen" = "1" ] && [ "$_h11_all_tags" = "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Best-effort fetch of the latest main; offline / no network -> don't block.
|
||||
git fetch origin main --quiet 2>/dev/null || exit 0
|
||||
git rev-parse --verify --quiet origin/main >/dev/null 2>&1 || exit 0
|
||||
|
||||
@@ -363,7 +363,7 @@ docker start ersatztv
|
||||
```
|
||||
- **Dispatcharr caches ErsatzTV's XMLTV.** Repointing its DB rows is not enough — it keeps serving a stale EPG full of dead `ersatztv:8409` artwork URLs (breaks Kodi artwork). Force a refresh (EPG source 9):
|
||||
```bash
|
||||
ssh timothy@192.168.1.99 'docker exec dispatcharr python manage.py shell -c \
|
||||
ssh timothy@192.168.1.29 'docker exec dispatcharr python manage.py shell -c \
|
||||
"from apps.epg.tasks import refresh_epg_data; refresh_epg_data(9)"'
|
||||
```
|
||||
- **`/api/health` returns 401** (needs an API key). The Telegraf probe has no `response_string_match`, so ErsatzTV reads as **unhealthy in Grafana** — a false alarm, and **pre-existing**, not caused by the move. The container healthcheck uses the unauthenticated internal `/health` and is unaffected.
|
||||
|
||||
@@ -122,14 +122,23 @@ jobs:
|
||||
# ersatztv#416: is this a docs-only change? If so, every heavy step below is skipped and this
|
||||
# REQUIRED job reports success in seconds. It still RUNS (never `if:`-skipped) so the required
|
||||
# context keeps reporting — see the workflow header and docs/ci-cd.md -> "Docs-only skip".
|
||||
# EVERY consequential `run:` step in this job marks itself as its FIRST act (ersatztv#756),
|
||||
# and the trailing `Assert every expected step executed` guard fails the job when one is
|
||||
# missing. This is a REQUIRED context on `main`, and a step the runner drops takes the job
|
||||
# GREEN having done no work — see scripts/ci-step-ran.sh for why that is fail-OPEN here while
|
||||
# the same drop in review-verdict.yml is fail-CLOSED.
|
||||
- name: Detect docs-only changes
|
||||
id: detect
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark detect
|
||||
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
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark revalidate
|
||||
scripts/ci-detect-already-validated.sh
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
@@ -141,7 +150,9 @@ jobs:
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: dotnet restore
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark restore
|
||||
dotnet restore
|
||||
|
||||
# Replaces setup-node's built-in `cache: npm`. The toolchain image supplies node/npm, but
|
||||
# the SPA's package downloads are project deps, so they stay cached per lockfile.
|
||||
@@ -156,36 +167,50 @@ jobs:
|
||||
- name: Install SPA dependencies
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark npm-ci
|
||||
npm ci
|
||||
|
||||
- name: Check generated SPA API client
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm run check:api
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark check-api
|
||||
npm run check:api
|
||||
|
||||
- name: Lint SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm run lint
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark lint
|
||||
npm run lint
|
||||
|
||||
- name: Typecheck SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm run typecheck
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark typecheck
|
||||
npm run typecheck
|
||||
|
||||
- name: Test SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm test -- --run
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark web-test
|
||||
npm test -- --run
|
||||
|
||||
- name: Build SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark web-build
|
||||
npm run build
|
||||
|
||||
- name: Strip Scanner project ref (matches Docker build)
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark strip-scanner
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
# Start the true peak-anon sampler just before the memory-heavy dotnet Build/Test/Coverage so
|
||||
# its high-water mark spans them (SPA build/test above are comparatively light). Paired with the
|
||||
@@ -199,13 +224,16 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark build
|
||||
dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: >-
|
||||
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
|
||||
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark dotnet-test
|
||||
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal \
|
||||
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
|
||||
|
||||
# Coverage reporting (ersatztv#15 scope item 4): coverlet.collector emits a Cobertura report
|
||||
# per test project (via --collect above); ReportGenerator merges them into a human-readable
|
||||
@@ -258,6 +286,43 @@ jobs:
|
||||
continue-on-error: true
|
||||
run: scripts/ci-peak-anon.sh report
|
||||
|
||||
# THE DROPPED-STEP GUARD (ersatztv#756). Every `run:` step above records that it began; this
|
||||
# asserts the whole expected SET was recorded. A step the runner declines to interpolate is
|
||||
# DROPPED and still concludes `success` (ersatztv#751), so without this a REQUIRED context
|
||||
# reports green having done no work — fail-OPEN, and strictly worse than the fail-CLOSED
|
||||
# version of the same bug that #751 fixed in review-verdict.yml.
|
||||
#
|
||||
# NO `if:` HERE, WHICH IS A DELIBERATE DEPARTURE FROM THE #751 GUARD and the one decision in
|
||||
# this block that is easy to "fix" wrongly. That guard uses `if: always()` because its job has
|
||||
# exactly one real step, so there is no ordinary red for it to talk over. Here there are
|
||||
# twelve, and a genuine failure in an early one (a lint error, a failing test) SKIPS every
|
||||
# later step — an `always()` guard would then announce "these steps never executed: typecheck
|
||||
# web-test build dotnet-test" on top of every normal red build. That is not a dropped step, it
|
||||
# is the runner doing what it is told, and a guard that cries wolf on every red build is a
|
||||
# guard that gets deleted.
|
||||
#
|
||||
# The default `if:` is `success()`, which is exactly the condition wanted, and the invariant it
|
||||
# rests on is worth stating because it is what makes the omission safe rather than lucky: this
|
||||
# step is skipped ONLY when an earlier step failed, and an earlier step failing already fails
|
||||
# the job. So `guard skipped => job red`, and the only path to a green job runs the guard. A
|
||||
# dropped step is invisible precisely because it concludes `success`, which keeps the job green
|
||||
# and therefore reaches here.
|
||||
#
|
||||
# ITS OWN BODY CANNOT BE DROPPED BY THE MECHANISM IT GUARDS AGAINST: it is a single command
|
||||
# with no expression delimiter anywhere in the scalar, so the runner has nothing to rewrite.
|
||||
# The two gate values come in through `env:`, which is interpolated PER VALUE — a bad payload
|
||||
# there cannot take the body with it (`ci.workflow-run-body-no-expressions`), and both paths
|
||||
# are held to naming a real context by
|
||||
# test_every_workflow_expression_names_a_REAL_context_or_function.
|
||||
- name: Assert every expected step executed (ersatztv#756)
|
||||
env:
|
||||
ETV_DOCS_ONLY: ${{ steps.detect.outputs.docs_only }}
|
||||
ETV_REVALIDATE_SKIP: ${{ steps.revalidate.outputs.skip }}
|
||||
run: >-
|
||||
scripts/ci-step-ran.sh assert
|
||||
--always detect revalidate
|
||||
--gated restore npm-ci check-api lint typecheck web-test web-build strip-scanner build dotnet-test
|
||||
|
||||
migrations:
|
||||
name: EF migration integrity (SQLite + MySql)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -328,14 +393,21 @@ jobs:
|
||||
|
||||
# ersatztv#416: docs-only? Skip the build + migration replay; the job still reports success in
|
||||
# seconds. REQUIRED context, so it always RUNS (never `if:`-skipped). See the workflow header.
|
||||
# Same per-step marker contract as the `test` job above (ersatztv#756) — this is the other
|
||||
# REQUIRED context, so a dropped migration-replay step would report EF integrity green having
|
||||
# replayed nothing.
|
||||
- name: Detect docs-only changes
|
||||
id: detect
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark detect
|
||||
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
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark revalidate
|
||||
scripts/ci-detect-already-validated.sh
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
@@ -347,11 +419,15 @@ jobs:
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: dotnet restore
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark restore
|
||||
dotnet restore
|
||||
|
||||
- name: Build
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark build
|
||||
dotnet build --configuration Release --no-restore
|
||||
|
||||
# dotnet-ef is baked into the CI toolchain image (docker/ci/Dockerfile) and already on PATH
|
||||
# — no per-run `dotnet tool install`. Bump its version there (ersatztv#390).
|
||||
@@ -361,6 +437,7 @@ jobs:
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark sqlite
|
||||
echo "::group::SQLite model drift (has-pending-model-changes)"
|
||||
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
|
||||
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite
|
||||
@@ -384,6 +461,7 @@ jobs:
|
||||
MySql__ConnectionString: "Server=mysql;Port=3306;Database=ersatztv_migrations;Uid=root;Pwd=ersatztv;DefaultCommandTimeout=300;"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark mysql
|
||||
echo "::group::MySql model drift (has-pending-model-changes)"
|
||||
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
|
||||
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql
|
||||
@@ -418,6 +496,42 @@ jobs:
|
||||
# how the original defects escaped. The fixture itself is retained and is opt-in via
|
||||
# ETV_TEST_MYSQL_CONNECTION (skipped, visibly, without it). Re-arming it here is tracked by #627.
|
||||
|
||||
# THE DROPPED-STEP GUARD (ersatztv#756). Every `run:` step above records that it began; this
|
||||
# asserts the whole expected SET was recorded. A step the runner declines to interpolate is
|
||||
# DROPPED and still concludes `success` (ersatztv#751), so without this a REQUIRED context
|
||||
# reports green having done no work — fail-OPEN, and strictly worse than the fail-CLOSED
|
||||
# version of the same bug that #751 fixed in review-verdict.yml.
|
||||
#
|
||||
# NO `if:` HERE, WHICH IS A DELIBERATE DEPARTURE FROM THE #751 GUARD and the one decision in
|
||||
# this block that is easy to "fix" wrongly. That guard uses `if: always()` because its job has
|
||||
# exactly one real step, so there is no ordinary red for it to talk over. Here a genuine
|
||||
# failure in an early step (a failing `dotnet build`, a MySql replay error) SKIPS every later
|
||||
# step — an `always()` guard would then announce "these steps never executed: sqlite mysql" on
|
||||
# top of every normal red build. That is not a dropped step, it is the runner doing what it is
|
||||
# told, and a guard that cries wolf on every red build is a guard that gets deleted.
|
||||
#
|
||||
# The default `if:` is `success()`, which is exactly the condition wanted, and the invariant it
|
||||
# rests on is worth stating because it is what makes the omission safe rather than lucky: this
|
||||
# step is skipped ONLY when an earlier step failed, and an earlier step failing already fails
|
||||
# the job. So `guard skipped => job red`, and the only path to a green job runs the guard. A
|
||||
# dropped step is invisible precisely because it concludes `success`, which keeps the job green
|
||||
# and therefore reaches here.
|
||||
#
|
||||
# ITS OWN BODY CANNOT BE DROPPED BY THE MECHANISM IT GUARDS AGAINST: it is a single command
|
||||
# with no expression delimiter anywhere in the scalar, so the runner has nothing to rewrite.
|
||||
# The two gate values come in through `env:`, which is interpolated PER VALUE — a bad payload
|
||||
# there cannot take the body with it (`ci.workflow-run-body-no-expressions`), and both paths
|
||||
# are held to naming a real context by
|
||||
# test_every_workflow_expression_names_a_REAL_context_or_function.
|
||||
- name: Assert every expected step executed (ersatztv#756)
|
||||
env:
|
||||
ETV_DOCS_ONLY: ${{ steps.detect.outputs.docs_only }}
|
||||
ETV_REVALIDATE_SKIP: ${{ steps.revalidate.outputs.skip }}
|
||||
run: >-
|
||||
scripts/ci-step-ran.sh assert
|
||||
--always detect revalidate
|
||||
--gated restore build sqlite mysql
|
||||
|
||||
functional-e2e:
|
||||
name: Functional E2E (curl + UI contracts)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -529,6 +643,61 @@ jobs:
|
||||
# server. Its exit status is Playwright's.
|
||||
scripts/e2e-ui.sh
|
||||
|
||||
# THE DELIMITER BAN, RE-CHECKED ON THE RELEASE PATH ITSELF (ersatztv#767).
|
||||
#
|
||||
# The ban that keeps `build`'s `Smoke + IPTV E2E` from being silently dropped was enforced only by
|
||||
# `test_the_delimiter_banned_jobs_have_NO_expression_delimiter_in_any_run_body` in the
|
||||
# `script-tests` job of pr-checks.yml — `on: pull_request`, and NOT a required context. So the ban
|
||||
# was REVIEW-TIME only: nothing re-checked it on a `v*` tag push, which is precisely when the
|
||||
# candidate image is published and `DeployStack jazz-media` promotes it.
|
||||
#
|
||||
# WHY A JOB AND NOT A STEP INSIDE `build`. A step cannot protect the thing it shares a job with:
|
||||
# `build` is what publishes, so a guard step there fails OPEN if the runner drops it, and "my body
|
||||
# has no opener so I cannot be dropped" is circular when the only thing enforcing that property is
|
||||
# the same PR-only test being backstopped. As a `needs:` of `build`, a red here means `build` never
|
||||
# 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
|
||||
# `{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:`
|
||||
# body" and therefore has no drift surface. `small` is git-only, so Python is provisioned here the
|
||||
# same way `script-tests` does it.
|
||||
#
|
||||
# This job's OWN steps carry #756 markers and a trailing assert, so a drop inside THIS job is
|
||||
# caught too. That terminates the regress at the same axiom the sibling guards already rest on —
|
||||
# to fail open you must now drop the pytest step AND the assert step, rather than either one.
|
||||
scan:
|
||||
name: Delimiter ban (release path)
|
||||
runs-on: small
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark deps
|
||||
python3 -m pip install --disable-pip-version-check --quiet pytest pyyaml
|
||||
# The ban test plus the structural tests that hold this job's own shape. NOT the whole
|
||||
# scripts/tests suite: that is `script-tests`'s job, it needs jq/git preflights, and an
|
||||
# unrelated pytest regression must not be able to block a release.
|
||||
- name: Run the delimiter-ban tests
|
||||
run: |
|
||||
"${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
|
||||
# No `if:` — see the sibling guards in `test`/`migrations` for why the default `success()` is
|
||||
# the wanted condition. Both keys are `--always`: every step in this job is unconditional.
|
||||
- name: Assert every expected step executed (ersatztv#756)
|
||||
run: >-
|
||||
scripts/ci-step-ran.sh assert
|
||||
--always deps ban
|
||||
|
||||
build:
|
||||
name: Build & push image (amd64)
|
||||
# Moved back off `small` (server-management#639). This is the one HEAVY job that
|
||||
@@ -545,7 +714,10 @@ jobs:
|
||||
# was queueing behind has drained. Real builds (main/tags) get the full
|
||||
# ubuntu-latest allotment: 4 CPUs / 10g on ci-runner (.127).
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, migrations]
|
||||
# `scan` (ersatztv#767) re-checks the delimiter ban on the release path. As a `needs:` its red
|
||||
# SKIPS this job outright, so a delimiter in `Smoke + IPTV E2E` can no longer reach the point
|
||||
# where an image is published and never booted.
|
||||
needs: [test, migrations, scan]
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -616,11 +788,33 @@ jobs:
|
||||
cache-from: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache
|
||||
cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache,mode=max,ignore-error=true
|
||||
|
||||
# THE TWO VALUES COME IN THROUGH `env:`, NOT INLINE (ersatztv#756). This step runs AFTER
|
||||
# `Build and push`, so on a `v*` tag the image is already in the registry as the release
|
||||
# candidate — and it is this smoke run that decides whether the candidate was ever booted at
|
||||
# all. A stray expression delimiter anywhere in this body (a comment is not inert — #751) would
|
||||
# DROP the step and conclude the job `success`: a candidate published, never smoke-tested, and
|
||||
# `DeployStack jazz-media` promotes exactly that image. `env:` is interpolated PER VALUE, so a
|
||||
# bad payload there fails that value instead of taking the whole body with it, and with the
|
||||
# body delimiter-free the class is unreachable here — held by
|
||||
# test_the_delimiter_banned_jobs_have_NO_expression_delimiter_in_any_run_body.
|
||||
#
|
||||
# The ban IS re-checked on the release path now (ersatztv#767): the `scan` job above runs the
|
||||
# PyYAML-based ban test and is a `needs:` of this job, so a delimiter here means `build` never
|
||||
# runs and no image is published. Do not re-add the note that once stood here saying the ban is
|
||||
# "review-time only, tracked as #767" — that was true before the `scan` job existed.
|
||||
#
|
||||
# This step still carries no per-step markers, and that is a genuine (smaller) residual rather
|
||||
# than a dismissal: markers would additionally catch a drop caused by something OTHER than a
|
||||
# delimiter. Adding them needs a bucket modelling this step's publish-ref `if:`, which the
|
||||
# guard's always/gated buckets do not express. The delimiter class itself is covered.
|
||||
- name: Smoke + IPTV E2E (assert key endpoints)
|
||||
if: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && steps.detect.outputs.docs_only != 'true' }}
|
||||
env:
|
||||
SMOKE_SHORT_SHA: ${{ steps.meta.outputs.short }}
|
||||
SMOKE_RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
IMG="${IMAGE}:${{ steps.meta.outputs.short }}"
|
||||
NAME="etv-smoke-${{ github.run_id }}"
|
||||
IMG="${IMAGE}:${SMOKE_SHORT_SHA}"
|
||||
NAME="etv-smoke-${SMOKE_RUN_ID}"
|
||||
trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT
|
||||
echo "Pulling ${IMG}"
|
||||
docker pull "$IMG"
|
||||
|
||||
@@ -182,6 +182,47 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# NEVER WRITE AN EXPRESSION DELIMITER ANYWHERE IN THIS BODY, INCLUDING IN A COMMENT
|
||||
# (ersatztv#751). A shell comment is not inert here. The runner scans this whole `run:`
|
||||
# scalar for the expression opener before bash ever sees it, and one occurrence makes it
|
||||
# rewrite the ENTIRE body into a single `format(...)` call so the result can be spliced
|
||||
# back in. That rewrite is all-or-nothing: if the payload does not parse as an expression
|
||||
# the interpolation of the whole scalar fails, and — the part that made this expensive —
|
||||
# the runner DROPS THE STEP AND REPORTS THE JOB GREEN.
|
||||
#
|
||||
# That is exactly how this gate spent 2026-08-03 to 2026-08-06 dead: the #706 note above,
|
||||
# explaining why a concurrency group does not work, quoted a `concurrency:` snippet
|
||||
# containing a PR-number expression as ILLUSTRATION. `pr number` is not a valid
|
||||
# expression, so this step never ran, `review-verdict/h10` was never posted by anything
|
||||
# but a human hand, and both exemption classes silently stopped working while every run
|
||||
# reported success. The prose documenting a fix disabled the fix.
|
||||
#
|
||||
# Two things now stop a recurrence, and they are deliberately different in kind: the
|
||||
# start-marker below turns a dropped step into a RED job instead of a green one, and
|
||||
# `test_the_verdict_workflow_has_NO_expression_delimiter_in_any_run_body` in
|
||||
# scripts/tests/test_pr_changed_files.py rejects the delimiter at review time. Note that
|
||||
# the older workflow-shape tests all scan comment-STRIPPED text (`_code_lines`) precisely
|
||||
# so prose cannot redden them — which makes every one of them structurally blind to this
|
||||
# class. The new test reads the raw scalar for that reason. To describe an expression in
|
||||
# prose here, name it (`a github.event.pull_request.number expression`); do not quote the
|
||||
# delimiters.
|
||||
#
|
||||
# THE START MARKER (ersatztv#751). Written before anything else can fail, and read by the
|
||||
# `Assert the classifier actually executed` step below. Its absence means this step did
|
||||
# not begin — the silent-green failure mode — and that is now a job failure. It records
|
||||
# only that execution STARTED: every `exit 0` abstention path below is a legitimate
|
||||
# outcome, so completion is deliberately not what is asserted.
|
||||
# KEYED ON THE RUN, not a fixed name. Measured on this instance, `RUNNER_TEMP` is `/tmp` —
|
||||
# not a private per-job directory — so a fixed name lives at a path other jobs can also
|
||||
# write. The `small` lane starts a container per job today, which makes the file fresh in
|
||||
# practice, but that is a property of the lane rather than a guarantee, and a STALE marker
|
||||
# would satisfy the guard below on a run whose step was dropped: a silent PASS, the exact
|
||||
# failure mode this guard exists to remove. Including the run id and attempt means a marker
|
||||
# from any other run cannot answer for this one. If the runner does not export them the path
|
||||
# degrades to a fixed name, which is no worse than having no key at all.
|
||||
RAN_MARKER="${RUNNER_TEMP:-${GITHUB_WORKSPACE:-/tmp}}/h10-classifier-started-${GITHUB_RUN_ID:-norunid}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||
: > "$RAN_MARKER"
|
||||
|
||||
CONTEXT="review-verdict/h10"
|
||||
# The description this job writes when it repairs its own raced exemption (#706 race 2).
|
||||
# It is a SENTINEL, not just a message: `read_existing_verdict` recognises it, and the
|
||||
@@ -242,7 +283,19 @@ jobs:
|
||||
# containing executable hooks"). A derived rule has to be evaluated against the PR's own
|
||||
# file list, which is the very thing being classified — more moving parts inside a security
|
||||
# predicate, to remove a maintenance burden that is one line per new tooling directory.
|
||||
PROTECTED='^(\.claude/|\.codex/|\.gitea/|\.husky/|scripts/|docker/ci/)'
|
||||
# `CLAUDE.md` and `AGENTS.md` are listed even though they are `.md` files, because they are
|
||||
# not prose about the project — they are the documents that DEFINE the completion protocol,
|
||||
# the merge-consent convention and the H10 rule itself. `.claude/` being protected while the
|
||||
# file that specifies what `.claude/` enforces was docs-only-exempt is the same
|
||||
# self-exemption the header rules out, one directory over. Found by cold review
|
||||
# (ersatztv#751): driving the real classify body with a lone `CLAUDE.md` change produced
|
||||
# `review-verdict/h10=success`, "Exempt: docs-only change (no code, no protected path)".
|
||||
#
|
||||
# It is fixed HERE rather than deferred because this change is what makes it reachable
|
||||
# again: no exemption `success` was writable at all while the classify step was dropped, so
|
||||
# restoring the exemptions restores this hole with them. `README.md` is deliberately NOT
|
||||
# listed — it is ordinary prose and carries no enforcement.
|
||||
PROTECTED='^(\.claude/|\.codex/|\.gitea/|\.husky/|scripts/|docker/ci/|CLAUDE\.md$|AGENTS\.md$)'
|
||||
# Docs-only: prose and decision records. Deliberately narrower than the hook's pattern,
|
||||
# which also lets .claude/.gitea/.husky through — that carve-out is safe there only
|
||||
# because it falls through to a HUMAN PROMPT, whereas here it would post a green status
|
||||
@@ -328,11 +381,85 @@ jobs:
|
||||
read_existing_verdict() {
|
||||
local json row
|
||||
json=$(gh "$BASE_URL/repos/$REPO/commits/$SHA/status?limit=100") || json=""
|
||||
if [ -z "${json//[[:space:]]/}" ] || ! printf '%s' "$json" | jq -e '.statuses | type == "array"' >/dev/null 2>&1; then
|
||||
echo "::error::Could not read existing commit statuses for ${SHA:0:7}. Refusing to post anything rather than risk overwriting an existing verdict."
|
||||
# `.statuses` IS `null`, NOT `[]`, ON A HEAD WITH NO STATUSES YET — the same nil-slice
|
||||
# serialization as the timeline terminator, found by cold review of the fix for that one
|
||||
# (ersatztv#751). Measured on this instance: PR #739's head 5fa672e2 returns
|
||||
# `{"state":"pending","total_count":0,"statuses":null}`. An `array`-only gate read that as
|
||||
# unreadable and took the `exit 1` below, so the job posted NOTHING — fail-closed, but the
|
||||
# user-visible outcome is exactly the one this issue is about: an exempt PR left with no
|
||||
# status and, since ersatztv#743, no bypass. Fixing only the timeline site would have left
|
||||
# the identical bug one function away.
|
||||
#
|
||||
# `total_count` is required to agree, so `null` is accepted only as the empty case and not
|
||||
# as a body that merely lost its array. Anything else is still "cannot tell" and still
|
||||
# refuses to post, which is what protects an existing verdict from a transient API error.
|
||||
# WHETHER PAGE 1 IS THE WHOLE LIST CANNOT BE DECIDED FROM PAGE 1 (ersatztv#751). Two
|
||||
# plausible guards were tried and both are no-ops HERE, which is why this ended up as an
|
||||
# extra round-trip instead of an arithmetic test:
|
||||
#
|
||||
# * `.statuses | length` vs `.total_count` — `total_count` is the count for the PAGE
|
||||
# RETURNED, not for the commit. Measured at 1.27.1 on 3aed43c6 (6 contexts):
|
||||
# `?limit=1` gives `len=1, total_count=1`, `?limit=3` gives `len=3, total_count=3`.
|
||||
# Equal by construction, so the check proves nothing.
|
||||
# * "refuse when the page came back FULL at the requested limit of 100" — this instance
|
||||
# caps `limit` at the server-wide `MAX_RESPONSE_ITEMS`, measured at 50
|
||||
# (`/issues?limit=100` returns 50). A response can therefore never contain 100 rows and
|
||||
# the comparison was DEAD CODE. The repo already knew this and said so in
|
||||
# `scripts/pr-changed-files.sh`, two test files and `ci.script-tests-job`; the guard was
|
||||
# written anyway. Hardcoding the cap instead would re-break the day it is reconfigured.
|
||||
#
|
||||
# So ask the server, and only when it matters. Completeness is needed ONLY to justify the
|
||||
# conclusion "no verdict exists on this head" — if the row IS on page 1 there is nothing
|
||||
# further to learn, since this endpoint returns the latest status per CONTEXT and the same
|
||||
# context cannot recur on a later page. When the row is absent, page 2 is read: any rows
|
||||
# there mean the list is longer than one page and the verdict could be sitting beyond it,
|
||||
# so this refuses rather than concluding absence. Cap-independent by construction.
|
||||
st_kind=$(printf '%s' "$json" | jq -r '.statuses | type' 2>/dev/null) || st_kind=""
|
||||
# NUMBER, not `jq -r` text: `jq -r` renders the JSON number 0 and the JSON string "0"
|
||||
# identically, so a schema-corrupted `"total_count": "0"` would satisfy a string compare
|
||||
# (cold review reproduced this). Requiring the type as well pins the accept path to a real
|
||||
# numeric zero.
|
||||
st_total=$(printf '%s' "$json" | jq -r 'if (.total_count | type) == "number" then (.total_count | tostring) else "x" end' 2>/dev/null) || st_total="x"
|
||||
st_ok=no
|
||||
case "$st_kind" in
|
||||
array) st_ok=yes ;;
|
||||
null) if [ "$st_total" = "0" ]; then st_ok=yes; fi ;;
|
||||
esac
|
||||
if [ -z "${json//[[:space:]]/}" ] || [ "$st_ok" != yes ]; then
|
||||
echo "::error::Could not read existing commit statuses for ${SHA:0:7} (.statuses was '${st_kind:-unparseable}', total_count '${st_total}'). Refusing to post anything rather than risk overwriting an existing verdict."
|
||||
exit 1
|
||||
fi
|
||||
row=$(printf '%s' "$json" | jq -r --arg c "$CONTEXT" '[.statuses[] | select(.context == $c)] | first // {}')
|
||||
# `// []` so the null case cannot hard-error here under `set -e` once it is accepted above.
|
||||
row=$(printf '%s' "$json" | jq -r --arg c "$CONTEXT" '[(.statuses // [])[] | select(.context == $c)] | first // {}')
|
||||
# THE COMPLETENESS PROBE, run only when page 1 shows no verdict — see the note above. An
|
||||
# unreadable or unexpected page 2 is treated as "cannot tell" and refuses, the same
|
||||
# direction as every other unreadable case here: concluding "no verdict exists" is what
|
||||
# licenses posting an exemption over one, so it is the conclusion that must be earned.
|
||||
if [ "$(printf '%s' "$row" | jq -r '.context // ""')" = "" ]; then
|
||||
more=$(gh "$BASE_URL/repos/$REPO/commits/$SHA/status?limit=100&page=2") || more=""
|
||||
if [ -z "${more//[[:space:]]/}" ]; then
|
||||
echo "::error::Could not read page 2 of the commit statuses for ${SHA:0:7}, so 'no verdict exists' cannot be established. Refusing to post anything."
|
||||
exit 1
|
||||
fi
|
||||
more_kind=$(printf '%s' "$more" | jq -r '.statuses | type' 2>/dev/null) || more_kind=""
|
||||
more_len=$(printf '%s' "$more" | jq -r '(.statuses // []) | length' 2>/dev/null) || more_len=""
|
||||
case "$more_kind" in
|
||||
null) ;;
|
||||
array)
|
||||
case "$more_len" in
|
||||
''|*[!0-9]*)
|
||||
echo "::error::Page 2 of the commit statuses for ${SHA:0:7} had a non-numeric length; refusing to conclude that no verdict exists."
|
||||
exit 1 ;;
|
||||
0) ;;
|
||||
*)
|
||||
echo "::error::${CONTEXT} was not on page 1 of the statuses for ${SHA:0:7}, but page 2 carries ${more_len} more row(s) — the list is longer than one page and an existing verdict may be beyond it. Refusing to post anything rather than overwrite a verdict this job cannot see. A human verdict clears this: scripts/post-review-verdict.sh ${PR} MERGEABLE."
|
||||
exit 1 ;;
|
||||
esac ;;
|
||||
*)
|
||||
echo "::error::Page 2 of the commit statuses for ${SHA:0:7} was '${more_kind:-unparseable}'; refusing to conclude that no verdict exists."
|
||||
exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
ex_state=$(printf '%s' "$row" | jq -r '.status // ""')
|
||||
ex_creator=$(printf '%s' "$row" | jq -r '.creator.login // ""')
|
||||
ex_desc=$(printf '%s' "$row" | jq -r '.description // ""')
|
||||
@@ -410,8 +537,8 @@ jobs:
|
||||
# run 7521 (`synchronize`) had started.
|
||||
#
|
||||
# WHY NOT A CONCURRENCY GROUP, which is the obvious answer and what #706 proposed. It does
|
||||
# not work here, also measured: with `concurrency: {group: …-${{ pr number }},
|
||||
# cancel-in-progress: false}` active on an identical probe, runs 7528 and 7529 still ran
|
||||
# not work here, also measured: with a `concurrency:` key grouping by PR number and
|
||||
# `cancel-in-progress: false` active on an identical probe, runs 7528 and 7529 still ran
|
||||
# CONCURRENTLY and 7528 ended 36s after 7529 began. Gitea 1.25.4 does auto-cancel superseded
|
||||
# `push` runs on a branch — a negative control with no `concurrency:` key at all showed that —
|
||||
# but that behaviour does NOT extend to `pull_request_target`. `cancel-in-progress: true` is
|
||||
@@ -438,19 +565,63 @@ jobs:
|
||||
# coming — a real stall. The retarget count moves only for the mutation that actually
|
||||
# invalidates a classification, and that mutation always brings its own re-run.
|
||||
#
|
||||
# Completeness is a guard, not an assumption (`ci.paged-endpoint-completeness`): the count is
|
||||
# Completeness is a guard, not an assumption (`ci.verdict-write-retarget-fence`; this
|
||||
# cited `ci.paged-endpoint-completeness` until 2026-08-06, a key that has never existed as
|
||||
# a record — resolve decisions through the catalog, never through a key or path quoted in a
|
||||
# comment). The count is
|
||||
# trusted ONLY when paging reached a validated EMPTY page. A short page, a non-array body, a
|
||||
# non-numeric length or the page cap all leave `rt_ok=no`, and an untrusted count is treated
|
||||
# below as "cannot tell" rather than as zero.
|
||||
count_retargets() {
|
||||
rt_count=0
|
||||
rt_ok=no
|
||||
local page=1 raw n m total=0
|
||||
local page=1 raw n m kind total=0
|
||||
while [ "$page" -le 20 ]; do
|
||||
raw=$(gh "$BASE_URL/repos/$REPO/issues/$PR/timeline?limit=50&page=${page}") || return 0
|
||||
if [ -z "${raw//[[:space:]]/}" ] || ! printf '%s' "$raw" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if [ -z "${raw//[[:space:]]/}" ]; then return 0; fi
|
||||
# A PAGE PAST THE END IS JSON `null` HERE, NOT `[]` (ersatztv#751). Measured on this
|
||||
# instance at Gitea 1.27.1 (page 1 non-empty, page 2 exhausted): page 2 of PR #752's timeline is the
|
||||
# four bytes `null`, while `/issues/{n}/comments` with no comments returns `[]`. The
|
||||
# instance is NOT consistent between endpoints, so the terminator has to be read from
|
||||
# what this endpoint actually does rather than from the shape a list endpoint "should"
|
||||
# return.
|
||||
#
|
||||
# This mattered far more than it looks. An `array`-only type gate treated `null` as
|
||||
# unreadable, so the walk NEVER reached a validated empty page, `rt_ok` was never `yes`
|
||||
# for ANY pull request, and the fence below therefore withheld every exemption
|
||||
# `success` — permanently. Renovate and docs-only PRs got no status at all rather than
|
||||
# an auto-pass, which is the same user-visible outcome as ersatztv#751 arriving by a
|
||||
# completely different route. It was invisible because it shipped in the SAME commit
|
||||
# (8f6d4f443) that stopped this step from executing at all, so the fence had never once
|
||||
# run in production, and because the test double asserted the wrong shape — it printed
|
||||
# `[]` for a page past the end while claiming to mirror measured reality.
|
||||
#
|
||||
# Read the type as a VALUE rather than through `jq -e`. `jq -e` reports the truthiness
|
||||
# of the last output, so distinguishing "the body is null" from "the predicate is
|
||||
# false" through it means relying on the same exit-status subtlety that already bit this
|
||||
# workflow once at jq 1.6 (ersatztv#647). A `case` over `jq -r 'type'` cannot be read
|
||||
# two ways. Anything that is neither `null` nor `array` is still "cannot tell".
|
||||
kind=$(printf '%s' "$raw" | jq -r 'type' 2>/dev/null) || kind=""
|
||||
# `null` counts as exhaustion only from page 2 ON. THE INVARIANT, not a figure that
|
||||
# rots: a real PR's timeline always carries at least one event on page 1 (it is created
|
||||
# by a push, which is itself an event), so a `null` FIRST page is anomalous rather than
|
||||
# empty. Spot-checked across #752/#753/#749/#739/#717, all non-empty; the counts
|
||||
# themselves are deliberately not recorded here because timelines grow and an earlier
|
||||
# version of this comment cited five numbers of which three were stale within days.
|
||||
# Trusting a zero count from an anomalous first page would
|
||||
# mean trusting that no retarget happened on the strength of a response we cannot
|
||||
# explain. Requiring one real page keeps the property the walk is for: something was
|
||||
# actually read. A PR that somehow has an empty first page falls through to `rt_ok=no`,
|
||||
# which withholds the exemption and asks for a human verdict — the safe direction.
|
||||
# This narrows rather than closes the general concern: a wrong `null` on page 3 is still
|
||||
# read as exhaustion, and no bounded number of round-trips can rule that out.
|
||||
case "$kind" in
|
||||
null)
|
||||
if [ "$page" -gt 1 ]; then rt_ok=yes; rt_count=$total; fi
|
||||
return 0 ;;
|
||||
array) ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
n=$(printf '%s' "$raw" | jq -r 'length')
|
||||
case "$n" in ''|*[!0-9]*) return 0 ;; esac
|
||||
if [ "$n" -eq 0 ]; then rt_ok=yes; rt_count=$total; return 0; fi
|
||||
@@ -471,10 +642,12 @@ jobs:
|
||||
# --- Whose verdict is it? (ersatztv#698 route 3) -------------------------------------
|
||||
# This short-circuit used to exit on ANY existing `success`, which made an exemption this job
|
||||
# wrote indistinguishable from a verdict a human wrote. That is what let a forged exemption
|
||||
# survive: obtained once — via the route-1 retarget race, a sibling workflow holding
|
||||
# status-write credentials (#697), a direct API call, or inheritance across PRs by sha (#663)
|
||||
# — it was thereafter accepted unchanged on every run, because the guard exited before it
|
||||
# looked at the PR, the base, the author or the files.
|
||||
# survive: obtained once — via the route-1 retarget race, a collaborator's own token, the
|
||||
# `GITEA_TOKEN` injected into every job, `RENOVATE_TOKEN`, a direct API call, or inheritance
|
||||
# across PRs by sha (#663) — it was thereafter accepted unchanged on every run, because the
|
||||
# guard exited before it looked at the PR, the base, the author or the files. (`#697`'s
|
||||
# registry credential can no longer post a status at all — see `ci.actions-credential-scoping`
|
||||
# — but that closes only one route; the others above still work.)
|
||||
#
|
||||
# The guard still exists for its original reason: re-posting `pending` over a real human
|
||||
# verdict would un-approve a reviewed head and stall the PR. So it discriminates by PROVENANCE.
|
||||
@@ -491,9 +664,11 @@ jobs:
|
||||
# machine-written") an unrecognised shape would be trusted — the fail-open this issue is about.
|
||||
#
|
||||
# What this does NOT claim: the test asks "was this POSTed by a user credential", NOT "by a
|
||||
# reviewer". `ETV_STATUS_AUTH` is basic auth, so head-controlled code can POST a success with a
|
||||
# non-null creator AND an attacker-chosen `Review-verdict:` description, which this guard then
|
||||
# preserves. That is #697 — provenance, not authentication.
|
||||
# reviewer". `ETV_STATUS_AUTH`'s registry credential can no longer POST a status at all (#697,
|
||||
# fixed by scoping it off `write:repository` — see `ci.actions-credential-scoping`), but any
|
||||
# OTHER user credential — a collaborator's own token, `RENOVATE_TOKEN` — can still POST a
|
||||
# success with a non-null creator AND an attacker-chosen `Review-verdict:` description, which
|
||||
# this guard then preserves. That is provenance, not authentication.
|
||||
read_existing_verdict
|
||||
if [ "$ex_human" = yes ] && { [ "$ex_state" = "success" ] || [ "$ex_state" = "failure" ]; }; then
|
||||
echo "${CONTEXT} is already '${ex_state}' on ${SHA:0:7}, written by '${ex_creator}' as a human verdict — leaving it alone."
|
||||
@@ -771,9 +946,21 @@ jobs:
|
||||
# live head), `/statuses/{sha}` a BARE ARRAY (24 rows on the same head) — hence the different
|
||||
# `type == "array"` guard here.
|
||||
#
|
||||
# No claim is made about the order rows come back in, because the check does not depend on
|
||||
# it: it selects by id against the high-water mark rather than inspecting the top of the
|
||||
# list. A verdict older than the mark is invisible to it no matter where it sits.
|
||||
# ORDER IS NOT RELIED ON *WITHIN* A PAGE — the check selects by id against the high-water
|
||||
# mark rather than inspecting the top of the list, so a verdict older than the mark is
|
||||
# invisible to it no matter where it sits. But this read is ONE PAGE, and `limit=100` clamps
|
||||
# to the server-wide `MAX_RESPONSE_ITEMS`, measured at 50 on this instance (ersatztv#751).
|
||||
# An earlier version of this comment claimed order-independence flatly; that is false the
|
||||
# moment a head carries more rows than the clamp, because a raced row can then sit on a page
|
||||
# this never reads. Measured: a probe head reached 33 rows after ~5 runs, and the ordering is
|
||||
# only coarsely newest-first (ids came back `33,32,31,30,28,29,27,…`), so >50 is ordinarily
|
||||
# reachable on a PR with a few CI reruns.
|
||||
#
|
||||
# That matters more here than anywhere else in this job, because this is the ONE path whose
|
||||
# failure direction is toward SUCCESS: missing a raced human `failure` leaves a forged green
|
||||
# standing over a rejection. So the page-2 probe below treats "there are rows I did not read"
|
||||
# as "assume raced" — the conservative direction — rather than as "no race found".
|
||||
# Full paging of this endpoint, including for the high-water mark above, is ersatztv#763.
|
||||
#
|
||||
# The repair is `pending`, NEVER a copy of the human's state. Re-posting their `failure`
|
||||
# would attribute a human verdict to this job — the exact provenance confusion the
|
||||
@@ -818,6 +1005,39 @@ jobs:
|
||||
and (((.description // "") | startswith("Review-verdict:"))))
|
||||
or ((.creator == null) and ((.description // "") == $rd))
|
||||
)] | length')
|
||||
# "NO RACE FOUND ON PAGE 1" IS NOT "NO RACE" (ersatztv#751). If page 1 was not the whole
|
||||
# list, a raced row can be beyond it, so an unread page is treated as a race rather than
|
||||
# as absence. Only checked when page 1 looked clean — a race already found needs no
|
||||
# further evidence. Unreadable page 2 also counts as raced: this is the fail-toward-
|
||||
# SUCCESS path, so uncertainty must resolve to `pending`, never to leaving green.
|
||||
#
|
||||
# BE HONEST ABOUT THE COST: a repair here is STICKY, not a one-run stall. It writes
|
||||
# `$REPAIR_DESC`, and the classification above refuses to grant an exemption over that
|
||||
# sentinel and re-writes it as a fixed point on every later run — so a SPURIOUS repair
|
||||
# (a head with more status rows than the page cap and no actual race) removes that head's
|
||||
# exemption permanently, and only a human `post-review-verdict.sh` clears it. That is
|
||||
# still the right direction, because the alternative is a forged green over a human
|
||||
# rejection. But it is a per-sha loss of the exemption, not an inconvenience, which is
|
||||
# the argument for replacing this with real paging (#763) rather than living with it.
|
||||
if [ "$raced" = "0" ]; then
|
||||
more_hist=$(gh "$BASE_URL/repos/$REPO/statuses/$SHA?limit=100&page=2") || more_hist="UNREADABLE"
|
||||
if [ "$more_hist" = "UNREADABLE" ]; then
|
||||
echo "::warning::Could not read page 2 of the status history for ${SHA:0:7}; treating this as a possible raced verdict and repairing to pending rather than leaving an exemption green on an unverified head."
|
||||
raced=1
|
||||
else
|
||||
mh_kind=$(printf '%s' "$more_hist" | jq -r 'type' 2>/dev/null) || mh_kind=""
|
||||
mh_len=$(printf '%s' "$more_hist" | jq -r 'if type == "array" then length else 0 end' 2>/dev/null) || mh_len=""
|
||||
case "${mh_kind}:${mh_len}" in
|
||||
null:*|array:0) ;;
|
||||
array:*)
|
||||
echo "::warning::The status history for ${SHA:0:7} runs past page 1 (${mh_len} more row(s)), so a raced human verdict could be on a page this job did not read. Repairing to pending rather than leaving the exemption green."
|
||||
raced=1 ;;
|
||||
*)
|
||||
echo "::warning::Page 2 of the status history for ${SHA:0:7} was '${mh_kind:-unparseable}'; treating as a possible raced verdict and repairing to pending."
|
||||
raced=1 ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
case "$raced" in
|
||||
''|*[!0-9]*)
|
||||
echo "::warning::Post-write verification for ${SHA:0:7} returned '${raced}' instead of a count; not acting on it."
|
||||
@@ -851,3 +1071,48 @@ jobs:
|
||||
if [ "$state" = "pending" ]; then
|
||||
echo "::notice::This PR needs an H10 review verdict for head ${SHA:0:7} before it can merge. After reviewing, run: scripts/post-review-verdict.sh ${PR} MERGEABLE"
|
||||
fi
|
||||
|
||||
# THE SILENT-GREEN HALF OF ersatztv#751, WHICH IS THE ACTUAL DEFECT. The stray expression
|
||||
# delimiter was one bug in one comment; this step exists because of how the runner REPORTED it.
|
||||
# A step it declines to interpolate is dropped and the job concludes `success`, so the gate was
|
||||
# dead for three days behind a green tick. Nothing surfaced it: the workflow's own status
|
||||
# context said success, and `review-verdict/h10` was merely ABSENT — which reads as "not
|
||||
# reviewed yet" on a normal PR and is indistinguishable from the correct pending state.
|
||||
#
|
||||
# This turns that class of failure — dropped for ANY reason, not only an interpolation failure
|
||||
# — into a red job. It asserts execution STARTED, never that it completed: the classifier has
|
||||
# several legitimate `exit 0` abstention paths (a human verdict landed mid-run, a mid-run
|
||||
# retarget, a sentinel appearing) and treating those as failures would redden ordinary PRs.
|
||||
#
|
||||
# `if: always()` so it also runs when the classifier failed on purpose — its fail-closed paths
|
||||
# exit non-zero, the job is already red, and this step then finds the marker and stays quiet
|
||||
# rather than piling a misleading second error on top.
|
||||
#
|
||||
# ITS OWN BODY MUST STAY EXPRESSION-FREE. A guard that can be dropped by the very mechanism it
|
||||
# guards against is worse than none, because its absence is also silent. No delimiters here, no
|
||||
# illustrative snippets, and `always()` is written bare as `if:` requires — the static test in
|
||||
# scripts/tests/test_pr_changed_files.py holds this to it.
|
||||
#
|
||||
# THAT A LATER STEP STILL RUNS AFTER AN EARLIER ONE IS DROPPED IS MEASURED, not assumed — it is
|
||||
# the premise this guard stands on, and the #751 report could not settle it because the
|
||||
# classifier was the job's last step, leaving nothing subsequent to observe. Established on this
|
||||
# instance by a scratch-base probe with a negative control (Gitea 1.27.1, 2026-08-06): run 1863
|
||||
# dropped the classifier on a reintroduced bad payload, logged `evaluating expression 'always()'
|
||||
# -> true`, ran THIS step, and the job concluded `failure`. Run 1866 is the positive control —
|
||||
# the classifier ran, posted its exemption, and this step found the marker at
|
||||
# `/tmp/h10-classifier-started-1866-1`, confirming the run-keyed path resolves.
|
||||
#
|
||||
# Had the runner dropped the remaining steps too, this guard could not work and the body would
|
||||
# have had to move into `scripts/`, where a one-line `run:` makes the class unreachable. It does
|
||||
# not, so it stays here. Re-measure if the runner is upgraded: this is the one assumption whose
|
||||
# failure is silent again.
|
||||
- name: Assert the classifier actually executed
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
marker="${RUNNER_TEMP:-${GITHUB_WORKSPACE:-/tmp}}/h10-classifier-started-${GITHUB_RUN_ID:-norunid}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||
if [ ! -f "$marker" ]; then
|
||||
echo "::error::The classify step did not execute AT ALL — no start marker at ${marker}. The runner dropped it (an interpolation failure over the run: body does this and still reports the job green; see ersatztv#751) or it was skipped. review-verdict/h10 has NOT been posted for this head, so exempt PRs (Renovate, docs-only) are silently unmergeable. Failing the job so this is visible instead of green."
|
||||
exit 1
|
||||
fi
|
||||
echo "The classify step executed (start marker present at ${marker})."
|
||||
|
||||
+3
-2
@@ -12,8 +12,9 @@ unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
|
||||
|
||||
# H11 (ersatztv#311): refuse to push a branch that is BEHIND origin/main — rebase, don't merge
|
||||
# main in (a merge drags in files you never touched, e.g. legacy-BOM .cs, and trips the format
|
||||
# hook on code that isn't yours). Fail-open; escape with ETV_SKIP_REBASE_CHECK=1.
|
||||
./.claude/hooks/prepush-rebase-check.sh || exit 1
|
||||
# hook on code that isn't yours). Fail-open; escape with ETV_SKIP_REBASE_CHECK=1. Exempts a
|
||||
# tag-only push (ersatztv#719) — forward the ref lines captured above so it can tell.
|
||||
printf '%s\n' "$_prepush_refs" | ./.claude/hooks/prepush-rebase-check.sh || exit 1
|
||||
|
||||
# H13 (ersatztv#416 session): refuse to push when a file in the pushed diff still has uncommitted
|
||||
# working-tree/index changes — the pushed commit wouldn't match what you built/reviewed (the #416
|
||||
|
||||
@@ -85,9 +85,11 @@ Every task that closes a Gitea issue MUST complete ALL of these before it is con
|
||||
**Merge-consent is derived from state, not asserted (`## Done-when` convention — ersatztv#303 H6 + H10).** Any issue whose PR will merge to `main` should carry a `## Done-when` section in its **issue body** — a checklist of completion criteria (always include an "adversarial review passed" box; add per-issue criteria like tests-green, docs-updated, live-E2E). Two hooks derive merge-consent from it so a premature merge is blocked *by construction*, not by memory:
|
||||
- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **auto-grants** a merge (emits `permissionDecision: allow`, so **no** redundant mechanical prompt fires) only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked **and** a `Review-verdict:` comment references the PR's *current head sha* (**H10**); **denies** on an unticked box, red CI, or a stale/negative review verdict; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no `Review-verdict:` comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state **is** the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate **asks** (ersatztv#314). **The H10 review-verdict convention**: after an adversarial/Codex review of a PR (or its latest fix commit), run **`scripts/post-review-verdict.sh <pr> <MERGEABLE|APPROVED|BLOCKED|NOT-MERGEABLE> [note]`** — it posts both the `Review-verdict: … @ <head-sha>` comment and the sha-bound `review-verdict/h10` commit status, proving the *latest* commit was reviewed rather than a stale earlier diff (ersatztv#242). Do not hand-write the comment: the **status** is the required check branch protection enforces, and a comment alone leaves it absent.
|
||||
- **The gate is enforced server-side, per sha (ersatztv#622).** `review-verdict/h10` is a required status check on `main`. Because a commit status belongs to one sha, a commit pushed *after* an auto-merge is scheduled clears it and blocks the merge — closing the hole where `merge_when_checks_succeed` froze consent at scheduling time and Gitea later merged an unreviewed head. Renovate-authored and docs-only PRs are auto-passed by `.gitea/workflows/review-verdict.yml`, **except** when they touch `.claude/`, `.codex/`, `.gitea/`, `.husky/`, `scripts/` or `docker/ci/`. See `docs/ci-cd.md` → Review-verdict gate.
|
||||
- `.husky/pre-push` → `prepush-donewhen.sh` — a fail-open backstop that blocks a direct `git push origin main` whose commits `fix #N` an issue with unticked boxes.
|
||||
- `.husky/pre-push` → `prepush-donewhen.sh` — a fail-open backstop that blocks a direct `git push origin main` whose commits `fix #N` an issue with unticked boxes. **Since ersatztv#743 that push can no longer happen at all** (see below), so this hook is now belt-and-braces for a path the server refuses.
|
||||
|
||||
Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pass`** or `ETV_GITEA_TOKEN`; `ETV_GITEA_URL` overrides the base). Without them the merge hook asks and the push backstop is a no-op — the gate degrades to today's manual confirmation, never a silent pass. Docs-only PRs/pushes are exempt.
|
||||
**`main` is PR-only — there is no direct-push path any more (ersatztv#743, `release.main-direct-push-disabled`).** Branch protection carries `enable_push: false` **and** `block_admin_merge_override: true`: a direct `git push origin HEAD:main` is refused server-side at pre-receive for every account including a site admin, the contents API is refused too, and an admin cannot `force_merge` past a missing or red required context. This is what makes `review-verdict/h10` load-bearing rather than conventional — Gitea only evaluates `status_check_contexts` on the PR merge path, so before this the whole gate was skippable with no forgery. Practically: **every** change to `main` goes through a PR, including a one-line docs fix. Tag pushes are unaffected (separate mechanism), so the release cut is unchanged.
|
||||
|
||||
Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pass`** or `ETV_GITEA_TOKEN`; `ETV_GITEA_URL` overrides the base). Without them the merge hook asks and the push backstop is a no-op — the gate degrades to today's manual confirmation, never a silent pass. Docs-only PRs are exempt from the *review-verdict* gate; the direct-push exemption is moot now that direct pushes are refused outright.
|
||||
|
||||
**The 7 mandatory completion steps and the `## Closing record` comment template** live in the
|
||||
`closing-an-issue` skill (`.claude/skills/closing-an-issue/SKILL.md`) — invoke it (or `/done`)
|
||||
|
||||
@@ -21,4 +21,16 @@
|
||||
<ProjectReference Include="..\ErsatzTV.Mcp\ErsatzTV.Mcp.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
The generated OpenAPI document is the wire contract the MCP catalog wraps. Copying it into the
|
||||
test output lets ToolCatalogTests assert that every write tool declares exactly the request-body
|
||||
fields its endpoint accepts, so a new DTO property cannot drift out of a tool schema unnoticed
|
||||
(issue #754). Regenerated by scripts/update-openapi.sh.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<Content Include="..\ErsatzTV\wwwroot\openapi\v1.json"
|
||||
Link="openapi\v1.json"
|
||||
CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -77,9 +77,22 @@ public class ToolCatalogTests
|
||||
// Enums must NOT be forced required (they have server-side defaults).
|
||||
createRequired.ShouldNotContain("streamingMode");
|
||||
|
||||
// Update carries the same body fields plus the route id.
|
||||
update.InputSchema.RootElement.GetProperty("properties").TryGetProperty("id", out _).ShouldBeTrue();
|
||||
update.InputSchema.RootElement.GetProperty("properties").TryGetProperty("showInEpg", out _).ShouldBeTrue();
|
||||
// Update carries the create body fields plus the route id...
|
||||
JsonElement updateProps = update.InputSchema.RootElement.GetProperty("properties");
|
||||
updateProps.TryGetProperty("id", out _).ShouldBeTrue();
|
||||
updateProps.TryGetProperty("showInEpg", out _).ShouldBeTrue();
|
||||
|
||||
// ...plus graphicsElementIds, which is on UpdateChannelRequest only. PUT is a full replace, so
|
||||
// while the tool could not express this field an agent following the tool's own "send the full
|
||||
// desired state" instruction silently detached every graphics element (issue #754).
|
||||
updateProps.TryGetProperty("graphicsElementIds", out JsonElement graphicsElementIds).ShouldBeTrue();
|
||||
graphicsElementIds.GetProperty("type").GetString().ShouldBe("array");
|
||||
graphicsElementIds.GetProperty("items").GetProperty("type").GetString().ShouldBe("integer");
|
||||
|
||||
// Create must NOT send it: CreateChannelRequest has no such property, and the tool schema is
|
||||
// additionalProperties:false. This is why it is declared on the update tool rather than in the
|
||||
// shared ChannelFields().
|
||||
createProps.TryGetProperty("graphicsElementIds", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -256,4 +269,248 @@ public class ToolCatalogTests
|
||||
tool.QueryParameters.ShouldNotBeNull();
|
||||
tool.QueryParameters!.ShouldContain("deep");
|
||||
}
|
||||
|
||||
// #754: ToolCatalog declared 27 of UpdateChannelRequest's 28 properties. The missing one was
|
||||
// graphicsElementIds, and because PUT /api/v1/channels/{id} is a FULL REPLACE the omission was not
|
||||
// merely "one field you cannot set" — an agent that GET-edit-PUT the channel, exactly as the tool's
|
||||
// description tells it to, detached every graphics element (including the On Now/Next overlay) with
|
||||
// a 200 and no error. The same shape was live on ersatztv_update_schedule, which omitted
|
||||
// padToNearestMinute and silently cleared a configured pad.
|
||||
//
|
||||
// Neither is fixable by counting fields once: the defect is that nothing tied the tool schema to the
|
||||
// contract it wraps. So this test asserts the tie for EVERY write tool against the generated OpenAPI
|
||||
// document (the actual wire contract, linked into the test output by the csproj). A new property on
|
||||
// any request DTO now fails here until the catalog declares it.
|
||||
[Test]
|
||||
public void Every_Write_Tool_Should_Declare_Exactly_Its_OpenApi_Request_Body_Fields()
|
||||
{
|
||||
using JsonDocument spec = LoadOpenApiDocument();
|
||||
JsonElement paths = spec.RootElement.GetProperty("paths");
|
||||
|
||||
ToolDefinition[] writeTools = ToolCatalog.All
|
||||
.Where(t => t.HttpMethod == HttpMethod.Post
|
||||
|| t.HttpMethod == HttpMethod.Put
|
||||
|| t.HttpMethod == HttpMethod.Patch)
|
||||
.ToArray();
|
||||
|
||||
// Pin the covered set rather than trusting the filter. A tool that stopped being a write verb,
|
||||
// or a new write tool, must show up as a change here — a bare loop over a filtered set passes
|
||||
// just as happily when the set silently shrinks to nothing.
|
||||
string[] expectedWriteTools =
|
||||
[
|
||||
"ersatztv_add_collection_items",
|
||||
"ersatztv_create_channel",
|
||||
"ersatztv_create_collection",
|
||||
"ersatztv_create_playout",
|
||||
"ersatztv_create_schedule",
|
||||
"ersatztv_create_smart_collection",
|
||||
"ersatztv_enable_jellyfin_library_sync",
|
||||
"ersatztv_refresh_jellyfin_libraries",
|
||||
"ersatztv_reset_channel_playout",
|
||||
"ersatztv_scan_jellyfin_collections",
|
||||
"ersatztv_scan_library",
|
||||
"ersatztv_update_channel",
|
||||
"ersatztv_update_collection",
|
||||
"ersatztv_update_collection_custom_order",
|
||||
"ersatztv_update_playout",
|
||||
"ersatztv_update_schedule",
|
||||
"ersatztv_update_smart_collection"
|
||||
];
|
||||
|
||||
writeTools.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal)
|
||||
.ShouldBe(expectedWriteTools.OrderBy(n => n, StringComparer.Ordinal));
|
||||
|
||||
foreach (ToolDefinition tool in writeTools)
|
||||
{
|
||||
paths.TryGetProperty(tool.PathTemplate, out JsonElement pathItem)
|
||||
.ShouldBeTrue($"{tool.Name}: {tool.PathTemplate} is not in the OpenAPI document");
|
||||
|
||||
string verb = tool.HttpMethod.Method.ToLowerInvariant();
|
||||
pathItem.TryGetProperty(verb, out JsonElement operation)
|
||||
.ShouldBeTrue($"{tool.Name}: {verb.ToUpperInvariant()} {tool.PathTemplate} is not in the OpenAPI document");
|
||||
|
||||
Dictionary<string, string> declared = DeclaredBodyArguments(tool);
|
||||
Dictionary<string, string> accepted = RequestBodyProperties(spec, operation, tool.Name);
|
||||
|
||||
// Compare name AND type. Names alone would let a field drift to the wrong JSON type: the
|
||||
// tool would advertise "string" for an int?, the agent would send "30", and the API would
|
||||
// 400 — green test, broken tool.
|
||||
declared.Select(p => $"{p.Key}: {p.Value}").OrderBy(s => s, StringComparer.Ordinal)
|
||||
.ShouldBe(
|
||||
accepted.Select(p => $"{p.Key}: {p.Value}").OrderBy(s => s, StringComparer.Ordinal),
|
||||
customMessage:
|
||||
$"{tool.Name} declares body fields that do not match {verb.ToUpperInvariant()} {tool.PathTemplate}. "
|
||||
+ "A field the endpoint accepts but the tool omits is silently dropped on a full-replace "
|
||||
+ "write (#754); a field the tool sends but the endpoint does not accept is rejected; "
|
||||
+ "a field declared with the wrong type is rejected at the API.");
|
||||
}
|
||||
}
|
||||
|
||||
// #757, the sibling of the body guard above. Query parameters drift the same way and are WORSE for
|
||||
// reads: ToolArgumentValidator rejects undeclared arguments, so a parameter the tool omits is not
|
||||
// merely undocumented, it is unreachable — the caller cannot pass it at all. That is how #616's
|
||||
// paging omission hard-capped two tools at the first page. This covers EVERY tool, not just the
|
||||
// write verbs, because the drift that existed when this was written was entirely on reads.
|
||||
[Test]
|
||||
public void Every_Tool_Should_Declare_Exactly_Its_OpenApi_Query_Parameters()
|
||||
{
|
||||
using JsonDocument spec = LoadOpenApiDocument();
|
||||
JsonElement paths = spec.RootElement.GetProperty("paths");
|
||||
|
||||
// Every tool is covered, so an emptiness guard is enough here — there is no filter to escape.
|
||||
ToolCatalog.All.Count.ShouldBeGreaterThan(30);
|
||||
|
||||
// Accumulate rather than throwing on the first mismatch, so one run reports the WHOLE drift set.
|
||||
// Failing fast here would hand back one tool at a time and invite fixing them one at a time,
|
||||
// which is how the #754 twin stayed hidden in the first place.
|
||||
List<string> drift = [];
|
||||
|
||||
foreach (ToolDefinition tool in ToolCatalog.All)
|
||||
{
|
||||
paths.TryGetProperty(tool.PathTemplate, out JsonElement pathItem)
|
||||
.ShouldBeTrue($"{tool.Name}: {tool.PathTemplate} is not in the OpenAPI document");
|
||||
|
||||
string verb = tool.HttpMethod.Method.ToLowerInvariant();
|
||||
pathItem.TryGetProperty(verb, out JsonElement operation)
|
||||
.ShouldBeTrue($"{tool.Name}: {verb.ToUpperInvariant()} {tool.PathTemplate} is not in the OpenAPI document");
|
||||
|
||||
IReadOnlySet<string> declared = tool.QueryParameters ?? new HashSet<string>(StringComparer.Ordinal);
|
||||
HashSet<string> accepted = QueryParameterNames(operation);
|
||||
|
||||
string[] missing = accepted.Except(declared, StringComparer.Ordinal).OrderBy(n => n, StringComparer.Ordinal).ToArray();
|
||||
string[] phantom = declared.Except(accepted, StringComparer.Ordinal).OrderBy(n => n, StringComparer.Ordinal).ToArray();
|
||||
|
||||
if (missing.Length > 0 || phantom.Length > 0)
|
||||
{
|
||||
drift.Add(
|
||||
$"{tool.Name} ({verb.ToUpperInvariant()} {tool.PathTemplate}): "
|
||||
+ $"unreachable={string.Join(",", missing)} phantom={string.Join(",", phantom)}");
|
||||
}
|
||||
}
|
||||
|
||||
// A parameter the endpoint accepts but the tool omits is UNREACHABLE, not merely undocumented:
|
||||
// ToolArgumentValidator rejects undeclared arguments, so the caller cannot pass it at all
|
||||
// (#616 hard-capped two paged tools exactly this way). A phantom is the reverse — the tool
|
||||
// advertises something the endpoint ignores.
|
||||
drift.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private static HashSet<string> QueryParameterNames(JsonElement operation)
|
||||
{
|
||||
if (!operation.TryGetProperty("parameters", out JsonElement parameters))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return parameters.EnumerateArray()
|
||||
.Where(p => p.TryGetProperty("in", out JsonElement location)
|
||||
&& string.Equals(location.GetString(), "query", StringComparison.Ordinal))
|
||||
.Select(p => p.GetProperty("name").GetString())
|
||||
.OfType<string>()
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
// The body is every declared argument that is not routed elsewhere — mirroring exactly how
|
||||
// ErsatzTvApiClient builds the request, so this test cannot disagree with the code it guards.
|
||||
// DELETE is not compared: ErsatzTvApiClient sets hasBody for POST/PUT/PATCH only, so a body
|
||||
// argument on a DELETE tool would be silently dropped. No DELETE tool has one today.
|
||||
private static Dictionary<string, string> DeclaredBodyArguments(ToolDefinition tool)
|
||||
{
|
||||
var pathParameters = Regex.Matches(tool.PathTemplate, @"\{([^}]+)\}")
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
IReadOnlySet<string> queryParameters = tool.QueryParameters ?? new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
if (!tool.InputSchema.RootElement.TryGetProperty("properties", out JsonElement properties))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return properties.EnumerateObject()
|
||||
.Where(p => !pathParameters.Contains(p.Name)
|
||||
&& !queryParameters.Contains(p.Name)
|
||||
&& !string.Equals(p.Name, "ifMatch", StringComparison.Ordinal))
|
||||
.ToDictionary(p => p.Name, p => DeclaredType(p.Value), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
// The tool schema's own shape: a plain "type", plus the array element type where there is one.
|
||||
private static string DeclaredType(JsonElement property)
|
||||
{
|
||||
string type = property.GetProperty("type").GetString().ShouldNotBeNull();
|
||||
|
||||
return type == "array" && property.TryGetProperty("items", out JsonElement items)
|
||||
? $"array<{items.GetProperty("type").GetString()}>"
|
||||
: type;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> RequestBodyProperties(JsonDocument spec, JsonElement operation, string toolName)
|
||||
{
|
||||
// No request body at all (queue/scan POSTs) — the tool must send none either.
|
||||
if (!operation.TryGetProperty("requestBody", out JsonElement requestBody))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
JsonElement schema = requestBody
|
||||
.GetProperty("content")
|
||||
.GetProperty("application/json")
|
||||
.GetProperty("schema");
|
||||
|
||||
// Every request body in this document is a plain $ref to a component schema. Anything else
|
||||
// (allOf/inline/oneOf) is a contract shape this guard has not been taught to read, so fail
|
||||
// loudly rather than comparing against an empty set and reporting a false pass.
|
||||
schema.TryGetProperty("$ref", out JsonElement reference)
|
||||
.ShouldBeTrue($"{toolName}: request body schema is not a $ref; teach this test the new shape");
|
||||
|
||||
JsonElement schemas = spec.RootElement.GetProperty("components").GetProperty("schemas");
|
||||
string componentName = reference.GetString().ShouldNotBeNull().Split('/')[^1];
|
||||
|
||||
return schemas
|
||||
.GetProperty(componentName)
|
||||
.GetProperty("properties")
|
||||
.EnumerateObject()
|
||||
.ToDictionary(p => p.Name, p => SpecType(schemas, p.Value, toolName, p.Name), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
// Normalize the generator's shapes onto the catalog's vocabulary. Two forms appear in this
|
||||
// document: a nullable type as ["null", T] (the catalog has no nullable notion — optionality is
|
||||
// carried by `required`), and a $ref to a component, which for the enum fields is a string enum
|
||||
// and for `logo` is an object.
|
||||
private static string SpecType(JsonElement schemas, JsonElement property, string toolName, string fieldName)
|
||||
{
|
||||
if (property.TryGetProperty("$ref", out JsonElement reference))
|
||||
{
|
||||
string componentName = reference.GetString().ShouldNotBeNull().Split('/')[^1];
|
||||
return SpecType(schemas, schemas.GetProperty(componentName), toolName, fieldName);
|
||||
}
|
||||
|
||||
JsonElement type = property.GetProperty("type");
|
||||
|
||||
string[] types = type.ValueKind == JsonValueKind.Array
|
||||
? type.EnumerateArray().Select(t => t.GetString()).OfType<string>().Where(t => t != "null").ToArray()
|
||||
: [type.GetString().ShouldNotBeNull()];
|
||||
|
||||
// More than one non-null type is a shape this guard has not been taught to read; fail rather
|
||||
// than picking one and reporting a comparison that means nothing.
|
||||
types.Length.ShouldBe(1, $"{toolName}.{fieldName}: unexpected OpenAPI type union [{string.Join(", ", types)}]");
|
||||
|
||||
// The element schema is resolved through the same normalization: an array's items can itself be
|
||||
// a $ref to a component (ReplaceRemoteLibraryPreferencesRequest.libraries), which the catalog
|
||||
// declares as an object array.
|
||||
return types[0] == "array" && property.TryGetProperty("items", out JsonElement items)
|
||||
? $"array<{SpecType(schemas, items, toolName, fieldName)}>"
|
||||
: types[0];
|
||||
}
|
||||
|
||||
private static JsonDocument LoadOpenApiDocument()
|
||||
{
|
||||
string path = Path.Combine(AppContext.BaseDirectory, "openapi", "v1.json");
|
||||
|
||||
// A missing spec would make every assertion above vacuous, so it is an explicit failure.
|
||||
File.Exists(path).ShouldBeTrue(
|
||||
$"OpenAPI document not found at {path}; the test project links it from ErsatzTV/wwwroot/openapi/v1.json");
|
||||
|
||||
return JsonDocument.Parse(File.ReadAllText(path));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,14 +29,32 @@ public static class ToolCatalog
|
||||
Get("ersatztv_list_schedules", "List schedules.", "/api/v1/schedules"),
|
||||
Get("ersatztv_get_schedule", "Get a schedule by id.", "/api/v1/schedules/{id}", IdPath("Schedule id.")),
|
||||
Get("ersatztv_get_schedule_items", "Get a schedule's items. Emits the schedule ETag.", "/api/v1/schedules/{id}/items", IdPath("Schedule id.")),
|
||||
Get("ersatztv_list_playouts", "List playouts (paged).", "/api/v1/playouts", [], Page()),
|
||||
Get(
|
||||
"ersatztv_list_playouts",
|
||||
"List playouts (paged), optionally filtered by channel name.",
|
||||
"/api/v1/playouts",
|
||||
[],
|
||||
[
|
||||
Str(
|
||||
"query",
|
||||
"Optional case-insensitive substring match on the CHANNEL name (not the playout or schedule name); omit for all playouts.",
|
||||
arg: In.Query),
|
||||
.. Page()
|
||||
]),
|
||||
Get("ersatztv_get_playout", "Get a playout by id.", "/api/v1/playouts/{id}", IdPath("Playout id.")),
|
||||
Get(
|
||||
"ersatztv_get_playout_items",
|
||||
"Get upcoming items (and unscheduled gaps) for a playout (paged).",
|
||||
"/api/v1/playouts/{id}/items",
|
||||
[IdPath("Playout id.")],
|
||||
Page()),
|
||||
[
|
||||
Bool(
|
||||
"showFiller",
|
||||
"Include items whose filler kind is not None (pre/mid/post-roll, tail, fallback, guide-mode, deco); "
|
||||
+ "default false returns only non-filler items.",
|
||||
arg: In.Query),
|
||||
.. Page()
|
||||
]),
|
||||
Get("ersatztv_list_ffmpeg_profiles", "List FFmpeg profiles.", "/api/v1/ffmpeg/profiles"),
|
||||
Get("ersatztv_get_ffmpeg_profile", "Get an FFmpeg profile by id.", "/api/v1/ffmpeg/profiles/{id}", IdPath("FFmpeg profile id.")),
|
||||
Get(
|
||||
@@ -132,7 +150,8 @@ public static class ToolCatalog
|
||||
[Str("name", "Schedule name.", required: true), .. ScheduleFlags()]),
|
||||
Put(
|
||||
"ersatztv_update_schedule",
|
||||
"Update a program schedule's settings.",
|
||||
"Update a program schedule. Send the full desired state: every field is applied, so omitting "
|
||||
+ "padToNearestMinute CLEARS a configured pad (GET the schedule first to copy current values).",
|
||||
"/api/v1/schedules/{id}",
|
||||
[IdPath("Schedule id."), Str("name", "Schedule name.", required: true), .. ScheduleFlags()]),
|
||||
Delete("ersatztv_delete_schedule", "Delete a program schedule.", "/api/v1/schedules/{id}", IdPath("Schedule id.")),
|
||||
@@ -159,9 +178,22 @@ public static class ToolCatalog
|
||||
ChannelFields()),
|
||||
Put(
|
||||
"ersatztv_update_channel",
|
||||
"Update a channel. Send the full desired state; enum fields take the enum name (GET the channel first to copy current values).",
|
||||
"Update a channel. Send the full desired state; enum fields take the enum name (GET the channel first to copy current values). "
|
||||
+ "graphicsElementIds is part of that state: omitting it DETACHES every graphics element (e.g. the On Now/Next overlay), "
|
||||
+ "so copy it from ersatztv_get_channel unless you mean to clear it.",
|
||||
"/api/v1/channels/{id}",
|
||||
[IdPath("Channel id."), .. ChannelFields()]),
|
||||
[
|
||||
IdPath("Channel id."),
|
||||
.. ChannelFields(),
|
||||
|
||||
// Update-only: UpdateChannelRequest carries GraphicsElementIds, CreateChannelRequest does
|
||||
// not, so this cannot move into the shared ChannelFields() without making create send an
|
||||
// unknown property. PUT is a full replace, so omitting it detaches every attached element
|
||||
// with no error — issue #754.
|
||||
IntArray(
|
||||
"graphicsElementIds",
|
||||
"Ids of the graphics elements attached to the channel. Full replace: omit or send [] to detach all.")
|
||||
]),
|
||||
Post(
|
||||
"ersatztv_reset_channel_playout",
|
||||
"Queue a rebuild of a channel's playout (202 Accepted; 409 if a build is already running).",
|
||||
@@ -297,7 +329,12 @@ public static class ToolCatalog
|
||||
Bool("treatCollectionsAsShows", "Treat collections as shows."),
|
||||
Bool("shuffleScheduleItems", "Shuffle schedule items."),
|
||||
Bool("randomStartPoint", "Use a random start point."),
|
||||
Str("fixedStartTimeBehavior", "Fixed start-time behavior (enum name; GET a schedule to see valid values).")
|
||||
Str("fixedStartTimeBehavior", "Fixed start-time behavior (enum name; GET a schedule to see valid values)."),
|
||||
|
||||
// Both Create- and UpdateScheduleRequest carry this, so it belongs in the shared helper. The
|
||||
// update PUT is a full replace that writes the value unconditionally, so omitting it used to
|
||||
// clear a configured pad silently — the same #754 shape as channel graphicsElementIds.
|
||||
Int("padToNearestMinute", "Pad each item to the nearest N minutes; omit or send null for no padding.")
|
||||
];
|
||||
|
||||
// ---- Tool factories ----
|
||||
|
||||
+380
-8
@@ -413,6 +413,14 @@ the image build.
|
||||
`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`.
|
||||
**`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
|
||||
least one of required scope(s)`, the fix is to narrow what the job does, **never** to widen the
|
||||
token to `write:repository` or to put the admin password back. Note what the scope still reaches:
|
||||
`write:package` covers `ersatztv:prod` (the tag prod's stack follows) and `ersatztv-ci:<sha>` (the
|
||||
toolchain image five `container:` jobs execute), so this is the deployment supply chain, not an
|
||||
inert endpoint — see `ci.actions-credential-scoping`.
|
||||
4. `docker/build-push-action@v6`: amd64-only, `docker/Dockerfile`, `INFO_VERSION`
|
||||
build-arg, registry layer cache (`type=registry,ref=…:buildcache`,
|
||||
`cache-to … ignore-error=true`).
|
||||
@@ -597,6 +605,167 @@ CI-validated, so the tree-match check correctly declines. So the skip is a genui
|
||||
win (clean, up-to-date, un-rebased merges in quiet periods) — correct-but-conservative by
|
||||
construction, not a general dedup. It never fires unsafely; when in doubt it runs the full matrix.
|
||||
|
||||
### Dropped-step guard on the required jobs (ersatztv#756)
|
||||
|
||||
`test` and `migrations` write the only two `docker-build.yml` contexts branch protection requires on
|
||||
`main`. A step the runner declines to interpolate is **dropped, and the job still concludes
|
||||
`success`** (ersatztv#751), so in these two jobs that failure is **fail-OPEN**: a required check
|
||||
reports green having done no work. In `review-verdict.yml` the same drop is fail-closed — the status
|
||||
is simply absent and the merge is blocked — which is why #751 fixed the safe direction first.
|
||||
|
||||
Two independent mechanisms hold it, and neither is redundant:
|
||||
|
||||
- **A static ban on expression delimiters** in any `run:` body of `test`, `migrations` **and
|
||||
`build`**. The drop mechanism *requires* an opener in the scalar, so this makes the class
|
||||
unreachable rather than merely detected — and it is the raw `${{` opener that is banned, not a
|
||||
well-formed pair, because an unclosed one triggers the same rewrite. When a step genuinely needs a
|
||||
value, pass it through the step's `env:` block, which is interpolated **per value**, so a bad
|
||||
payload there cannot take the body with it.
|
||||
|
||||
**Why `build` is in the ban although it is not a required context.** Its one delimiter-bearing body
|
||||
was `Smoke + IPTV E2E`, which runs *after* `Build and push` — so on a `v*` tag the image is already
|
||||
in the registry as the release candidate and that step is what decides whether the candidate was
|
||||
ever booted. A drop there publishes an unsmoked candidate, reports green, and `DeployStack
|
||||
jazz-media` promotes exactly that image. Its two payloads moved into the step's `env:`, so the ban
|
||||
cost nothing.
|
||||
|
||||
**The ban is re-checked on the release path itself (ersatztv#767).** It used to be enforced only by
|
||||
the `script-tests` job, which lives in `pr-checks.yml` (`on: pull_request`) and is **not** a
|
||||
required context — a *review-time* check on the PR that would introduce a delimiter, not a gate on
|
||||
the release. `pr-checks.yml` does not run on a `v*` tag push at all, so a delimiter that ever
|
||||
reached `main` would still drop `Smoke` on the tag build and go green; `main` being PR-only (#743)
|
||||
meant such a change had to pass through a PR where `script-tests` reddens, but a red on a
|
||||
non-required check does not block the merge server-side.
|
||||
|
||||
There is now a **`scan` job** (`Delimiter ban (release path)`) that runs the PyYAML-based ban test,
|
||||
and **`build` lists it in `needs:`**. That single edge is the fail-closed property: a red `scan`
|
||||
means `build` is skipped outright, so the image is never built, let alone pushed.
|
||||
|
||||
**Why a job and not a step inside `build`.** A step cannot protect the job it lives in. `build` is
|
||||
what publishes, so a guard step there fails **open** if the runner drops it — and the defence
|
||||
("the guard's own body has no opener, so it cannot be dropped") is circular when the only thing
|
||||
enforcing that property is the same PR-only test being backstopped. This was the first design and
|
||||
two independent reviews rejected it for exactly that.
|
||||
|
||||
**Why it runs the real pytest and not a bespoke scanner.** The same first cut hand-parsed the
|
||||
workflow YAML in stdlib Python, to avoid provisioning PyYAML on `build`'s bare runner. Review found
|
||||
~10 **false negatives** in that parser in one round — flow mappings (`{run: …}`), a quoted
|
||||
`"run":` key, aliases, multiline quoted scalars — making it strictly *weaker* than the check it
|
||||
backstopped, in the only direction that matters for a security gate. Running the existing test
|
||||
needs no second definition of "what is a `run:` body", so it has no drift surface at all. `scan`
|
||||
runs on `small` and provisions Python the same way `script-tests` does.
|
||||
|
||||
The wiring is held by `scripts/tests/test_ci_release_path_scan_job.py` — `build` depends on it, it
|
||||
carries **no job-level `if:`** (one that excluded the tag push would restore the hole; one that
|
||||
skipped the job would skip `build` too), no step is `continue-on-error`, it actually invokes the
|
||||
ban test, and every one of its own `run:` bodies is delimiter-free. Its steps also carry #756
|
||||
markers and a trailing assert, so a drop *inside this job* is caught as well.
|
||||
|
||||
What this does **not** claim: that no step can ever fail to run for a reason other than the
|
||||
interpolation drop. It moves the terminal assumption — to fail open you must now drop the pytest
|
||||
step **and** the assert step, rather than either one alone.
|
||||
|
||||
Measuring a guard on this path does **not** require cutting a release, and an earlier draft here
|
||||
claiming it would was simply wrong: `build` runs on every push to `main`
|
||||
(`if: github.event_name != 'pull_request'`), and a `workflow_dispatch` on any other ref runs the
|
||||
job while `Build and push` publishes nothing (its `push:` is gated on `main`/`v*`). That is how
|
||||
#767 was verified — see the decision record for the run ids.
|
||||
|
||||
`functional-e2e` is delimiter-free too but is deliberately **not** banned: it is
|
||||
advisory by declaration, and the rule is "ban where a drop is consequential", not "ban wherever it
|
||||
is currently free". `api-docs` and `format` keep one `github.base_ref` each in a detect step and
|
||||
gate nothing that ships.
|
||||
- **Runtime per-step markers**, for a step that fails to run for any *other* reason. Every `run:`
|
||||
step that is not `continue-on-error: true` calls
|
||||
`"$GITHUB_WORKSPACE/scripts/ci-step-ran.sh" mark <key>` as its **first act**, and the job's last
|
||||
step calls `ci-step-ran.sh assert --always … --gated …`, which fails the job when an expected key
|
||||
was never recorded.
|
||||
|
||||
**Per step, not per job.** A marker written by the first step only proves the job *began*, which was
|
||||
never in doubt. The drop that costs something is `Test`, `Build` or a migration replay — all well
|
||||
past step one — so a job-level marker would have been a guard that cannot see the case it exists for.
|
||||
|
||||
**The guard carries no `if:`, and that is deliberate.** The #751 guard uses `if: always()` because its
|
||||
job has one real step. These have a dozen, and a genuine failure in an early step legitimately skips
|
||||
every later one — an `always()` guard would then announce a false *"these steps never executed:
|
||||
typecheck web-test build dotnet-test"* on top of every ordinary red build, and a guard that cries wolf
|
||||
gets deleted. (`migrations` is smaller — six marked steps — but the same argument applies, and its
|
||||
guard comment is worded for its own keys rather than copied from `test`'s.) The default `if:` is `success()`, which is the wanted condition, and the invariant that
|
||||
makes relying on it safe rather than lucky is: **the guard is skipped only when an earlier step
|
||||
failed, and that already fails the job**. So *guard skipped ⇒ job red*, and every path to a green job
|
||||
runs the guard. A dropped step is invisible precisely *because* it concludes `success` — which keeps
|
||||
the job green and therefore reaches the guard.
|
||||
|
||||
That invariant has one path where it could plausibly be false and where being wrong would be silent:
|
||||
a step marked `continue-on-error: true` that FAILS. If that flipped `success()`, the guard would be
|
||||
skipped on a job that still concluded green — the guard rendered a no-op by exactly the failure mode
|
||||
it exists to catch, with no signal. The `test` job has three `continue-on-error` steps and two of them sit immediately before the guard,
|
||||
so this is a live path, not a theoretical one. **Measured** (scratch PR #766, run 1913 job 8075): the last
|
||||
advisory step was made to `exit 1`, the log carries `❌ Failure - Main Report peak container memory`,
|
||||
and the guard **still ran**, reported `All 12 expected step(s) executed`, and the job concluded
|
||||
`success`. A failing `continue-on-error` step does not flip `success()` on this runner, so the
|
||||
invariant holds where it mattered most. That run is also the `test` job's full twelve-key positive
|
||||
control on the build lane.
|
||||
|
||||
**Adding a step to either job?** Mark it, and add its key to that job's guard list in the right
|
||||
bucket (`--always` for the two detect steps, `--gated` for anything carrying the docs-only /
|
||||
already-validated `if:`). `scripts/tests/test_ci_dropped_step_guard.py` derives the expected set from
|
||||
the workflow, so an unmarked step or a bucket mismatch is a red — it does not rely on anyone
|
||||
remembering. One caveat, since this section is careful about it elsewhere: that red is `script-tests`,
|
||||
the same non-required, PR-only check discussed above. For the delimiter ban on `test`/`migrations`
|
||||
that hardly matters, because the runtime guard is the fail-closed backstop — but a **newly added,
|
||||
unmarked** step is caught by the static test *alone*, since the runtime guard cannot expect a key
|
||||
nobody declared.
|
||||
|
||||
**The marker path is keyed on job + run id + attempt** — and be precise about why, because the
|
||||
obvious justification is a #751 measurement that does *not* transfer. #751 found `RUNNER_TEMP` to be
|
||||
`/tmp` and called it "not a private per-job directory", but that was taken on `review-verdict.yml`,
|
||||
which runs *without* a `container:`. These two jobs run **inside** the CI toolchain image, so their
|
||||
`/tmp` is the job container's own and starts empty. The fresh container is therefore what actually
|
||||
rules out a stale marker here; the keying is defence in depth against a lane change nobody would
|
||||
think to re-check this against. `GITHUB_JOB` and `GITHUB_RUN_ID` are *measured* present and the
|
||||
script refuses without them rather than falling back to a name other runs share.
|
||||
`GITHUB_RUN_ATTEMPT` is required too — but **how** that was established is the part worth keeping,
|
||||
because the first two attempts at it were both worthless. Grepping a job log for the variable *name*
|
||||
proves nothing: logs do not dump the environment. Inferring it from the *absence* of the script's
|
||||
"not set" warning proves nothing either, because that warning goes to **stderr**, and whether step
|
||||
stderr reaches a job log here was itself never established — the control offered for that turned out
|
||||
to be an `::error::` line this script writes to *stdout*. So the script was made to **report its
|
||||
resolved identity on stdout**, where capture is not in question, and the answer was simply read off
|
||||
this change's own run: `Marker identity: job=test run=1916 attempt=1 (from the runner)`, and the same
|
||||
for `migrations`. Both required jobs, on the lane that matters.
|
||||
|
||||
That measurement is what promoted it from warn-and-default to required, and it is why the residual
|
||||
this paragraph used to describe — a rerun inheriting attempt 1's markers — no longer exists. The
|
||||
identity line stays, as the standing evidence a future reader checks first if the keying is ever
|
||||
doubted again.
|
||||
|
||||
**The premise was re-measured on the build lane.** The whole thing rests on the runner still executing
|
||||
a later step after dropping an earlier one. #751 established that on the `small` lane; these jobs run
|
||||
in a `container:` on `ubuntu-latest`, so it was measured there rather than assumed — scratch PR #765
|
||||
(Gitea 1.27.1, 2026-08-10) reintroduced the exact #751 defect in the `test` job's `revalidate` step.
|
||||
Recorded outcome (job `test`, run 1910, 20:03:49→20:13:31Z — a full 9m42s heavy run, so `Build` and
|
||||
`Test` really executed):
|
||||
|
||||
- `Unable to interpolate expression 'format('# PROBE ONLY … {0}\n…', pr number)'` at 20:04:06 — the
|
||||
step was **dropped**, exactly as #751 describes, and it reported conclusion `success`.
|
||||
- **Every other marked step still ran** — eleven markers were recorded, ten of them AFTER the drop
|
||||
(`restore npm-ci check-api lint typecheck web-test web-build strip-scanner build dotnet-test`),
|
||||
`detect` being the eleventh and earlier. The premise holds on this lane.
|
||||
- The guard ran at 20:13:29, reported `These steps of job 'test' never executed: revalidate`, and was
|
||||
the **only** ❌ in the entire job log — every other step succeeded. Without it this run would have
|
||||
concluded `success` having never executed that step, which is precisely the fail-open being closed.
|
||||
- Incidental but kept: the dropped step's output arrived as `ETV_REVALIDATE_SKIP:` **empty**, not
|
||||
`false` — the case the guard must read as "widen what is required", never as a skip.
|
||||
|
||||
**The positive control is the same run's `migrations` job**, which the probe did not touch: it marked
|
||||
all six steps, the guard reported `All 6 expected step(s) executed: detect revalidate restore build
|
||||
sqlite mysql`, and the job concluded **success**. So one run demonstrates both directions on the build
|
||||
lane — a drop caught and reddened, and a clean job passing. The twelve-step `test` positive control is
|
||||
this change's own CI run.
|
||||
|
||||
Full rationale: `docs/decisions/records/ci/required-job-step-execution-markers.md`.
|
||||
|
||||
### `docs-reminder` job (non-blocking, PR-only — in `pr-checks.yml`)
|
||||
|
||||
A lightweight nudge that enforces the CLAUDE.md "docs-update is part of done" rule for the
|
||||
@@ -765,6 +934,40 @@ from `Build ErsatzTV Image / …` to `PR Gates / …`) does not affect merges. T
|
||||
unreviewed commit from merging** (ersatztv#622). It is not produced by a job's success/failure; it
|
||||
is a commit status that `scripts/post-review-verdict.sh` POSTs onto one specific sha.
|
||||
|
||||
**`main` is PR-only AND admin-override-proof, and it takes both to make the check load-bearing**
|
||||
(ersatztv#743, `release.main-direct-push-disabled`). Gitea evaluates `status_check_contexts` when it
|
||||
**merges a PR** — a direct `git push origin HEAD:main` never consults them. So until 2026-08-05 the
|
||||
entire gate was skippable with no forgery at all, which was cheaper than every route enumerated in
|
||||
#697. `main` now carries **two** fields, and citing either alone is a mistake:
|
||||
|
||||
- `enable_push: false` — a direct push is refused server-side at pre-receive (`Not allowed to push to
|
||||
protected branch main`), for every account including a site admin. The contents API is refused too
|
||||
— measured, HTTP 403 `user cannot commit to repo`. The web editor, upload, apply-patch, revert and
|
||||
cherry-pick paths share that same `CanUserPush` predicate and are therefore expected to refuse as
|
||||
well, but were not probed (source-attested only).
|
||||
- `block_admin_merge_override: true` — without it (the default is `false`), a repo admin could
|
||||
`POST /pulls/{n}/merge` with `force_merge: true` and merge straight past a missing or red
|
||||
`review-verdict/h10`. Disabling push alone just moves the bypass from the push path to the merge
|
||||
path, since `timothy` is admin and is the identity every session already uses. **Source-attested,
|
||||
not probed** (Gitea 1.27 `CanBypassBranchProtection`): verifying it by experiment means merging an
|
||||
unreviewed PR, so the field was set rather than measured. Setting it is safe under either
|
||||
semantics; re-confirming the bypass itself rides with ersatztv#747.
|
||||
|
||||
**Operator recovery when a required context gets stuck.** `block_admin_merge_override: true` removes
|
||||
the "Merge (admin)" / `force_merge: true` escape that used to unstick a PR whose required context was
|
||||
absent or wrongly red — a recurring situation here (a killed run overwriting a newer green, an
|
||||
advisory red counted into the combined status, a gate workflow that cannot post). That escape is gone
|
||||
*by design*: it was also the bypass. The supported recovery is to fix the status
|
||||
(re-run the job, or re-post the verdict with `scripts/post-review-verdict.sh`); the last resort is to
|
||||
`PATCH .../branch_protections/main` setting `block_admin_merge_override: false`, merge, and set it
|
||||
straight back. Do the last one deliberately and say so in the PR — it is the one action that
|
||||
re-opens the hole this section exists to close.
|
||||
|
||||
Practical consequences: **every** change to `main` goes through a PR, including a one-line docs fix;
|
||||
and the client-side Husky guards (H6/H11/H13) remain useful friction but were never the control —
|
||||
they are fail-open and `--no-verify` bypasses them. Tag pushes are unaffected (separate mechanism;
|
||||
`tag_protections` is empty), so the release cut in "Cutting a release" still works unchanged.
|
||||
|
||||
**The hole it closes.** `pretooluse-merge-consent.sh` proves its three consent conditions at the
|
||||
moment the merge tool is called. Pass `merge_when_checks_succeed=true` and Gitea performs the merge
|
||||
*later*, against whatever head is green then — while the Done-when and review-verdict checks were
|
||||
@@ -834,7 +1037,18 @@ as a human verdict — a non-null `.creator.login` **and** a `Review-verdict:` d
|
||||
**re-derived** rather than inherited. (Measured: a status POSTed with a user credential carries a
|
||||
creator; one POSTed by an Actions job carries `"creator": null`.) Without this, an exemption obtained
|
||||
once was accepted unchanged on every later run. This is a *provenance* check, not an authentication
|
||||
one — someone who can POST statuses directly can still impersonate a verdict, which is ersatztv#697.
|
||||
one — someone who can POST statuses directly can still impersonate a verdict (ersatztv#697). That
|
||||
provenance asymmetry is *why* the credential scoping in `ci.actions-credential-scoping` mattered: a
|
||||
forgery through a **user** credential inherits as a human verdict, while one through a job's
|
||||
`GITEA_TOKEN` carries `creator: null` and is re-derived, so it must win a race. CI's registry secret
|
||||
was a user credential — the admin account — and no longer carries status-write. **`RENOVATE_TOKEN`
|
||||
still is one** (`write:repository`, a real bot account), and secrets are a per-repo store any
|
||||
PR-added workflow can reference, so that route is narrowed rather than closed; tightening this check
|
||||
from "non-null creator" to an allow-list of approved reviewers is what would close it
|
||||
(ersatztv#742). A collaborator's own personal token still can, and no repo-side change closes that.
|
||||
Note also that re-derivation is **not** a race the attacker can lose: it fires only on the trigger's
|
||||
`types`, and posting a status is not one of them, so a POST timed after the last PR event stands
|
||||
until the next one.
|
||||
|
||||
Deciding either exemption requires the PR's **complete** changed-file list, which the workflow does
|
||||
not compute itself: it calls `scripts/pr-changed-files.sh`, the single shared implementation also
|
||||
@@ -901,12 +1115,24 @@ as establishing that the gate cannot be forged (see the residual below, and ersa
|
||||
here **only** because this job never checks out or executes head-supplied code. Verified on this
|
||||
instance with four scratch PRs rather than inferred from GitHub; full rationale in
|
||||
`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 its `ETV_STATUS_AUTH`
|
||||
credentials can write statuses, so it can still forge `review-verdict/h10` — it must stay on
|
||||
`pull_request` because it builds the PR's code, so it needs a read-only status identity instead
|
||||
(ersatztv#697) — and the inventory is every workflow, not that one, because Gitea injects a
|
||||
write-capable `GITEA_TOKEN` into every job and branch protection binds the *context*, not its
|
||||
issuer. The exemption path has separate defects of its own (ersatztv#698). One operational
|
||||
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
|
||||
workflow, though: Gitea injects a write-capable `GITEA_TOKEN` into every job and branch protection
|
||||
binds the *context*, not its issuer. Gitea >=1.26 with the Actions default set to **Restricted**
|
||||
(server-management#714) binds the injected token, but does not close the class either — not against
|
||||
a personal token, and not against `RENOVATE_TOKEN` (ersatztv#742). **And none of it was necessary:
|
||||
direct pushes to `main` were server-side permitted, so the gate could be skipped without any forgery
|
||||
(ersatztv#743). That is now CLOSED — `main` carries `enable_push: false` **and**
|
||||
`block_admin_merge_override: true`, so it is reachable only through the PR merge path, the one path
|
||||
on which Gitea evaluates `status_check_contexts`, and an admin cannot `force_merge` past them
|
||||
(`release.main-direct-push-disabled` — neither field is citable alone).** Note the fix is *disabling* push, not whitelisting it: a
|
||||
push whitelist naming `timothy` was measured to still admit the push, and `timothy` is the identity
|
||||
every session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form would have
|
||||
closed nothing. The block binds a site admin at pre-receive but not a credential that can first
|
||||
PATCH branch protection off — an accepted residual, recorded in that decision. The
|
||||
exemption path has separate defects of its own (ersatztv#698). One operational
|
||||
consequence of the trigger change: a PR whose base is not `main` now gets **no**
|
||||
`review-verdict/h10` at all. That is fail-closed. `edited` **is** now among the trigger's `types`
|
||||
(ersatztv#698), so a PR retargeted onto `main` reclassifies instead of staying statusless until its
|
||||
@@ -937,7 +1163,153 @@ trust the editing PR's own checks. Verify the way ersatztv#672 did:
|
||||
branches.
|
||||
|
||||
The same shape is what makes a `branches:`/`types:` change verifiable at all, since neither can be
|
||||
observed from the editing PR.
|
||||
observed from the editing PR. Note step 2 requires the scratch **base**'s own `branches:` filter to
|
||||
name that base — the definition comes from the base, so a base the filter does not admit produces no
|
||||
run at all.
|
||||
|
||||
⚠️ **Never write an expression delimiter inside a `run:` body here — a comment is NOT inert**
|
||||
(ersatztv#751, `ci.workflow-run-body-no-expressions`). A `run:` body is not shell when the runner
|
||||
reads it. The runner scans the whole scalar for the expression opener and, on finding one, rewrites
|
||||
the **entire** body into a single `format(...)` call so the result can be spliced back in. That
|
||||
rewrite is all-or-nothing: a payload that does not evaluate fails the interpolation of the whole
|
||||
scalar, and **the runner then drops the step and concludes the job `success`**.
|
||||
|
||||
That is not hypothetical. From 8f6d4f443 (2026-08-03) to 2026-08-06 the classify step **never ran**.
|
||||
The #706 note above, explaining why a concurrency group does not work here, quoted a `concurrency:`
|
||||
snippet containing a PR-number expression *as an illustration*, in a shell comment. `pr number` is not
|
||||
a valid expression. So `review-verdict/h10` was posted by nothing but a human hand for three days,
|
||||
both exemption classes silently stopped working, and every run reported success. The prose documenting
|
||||
a fix disabled the fix.
|
||||
|
||||
**The silent green is the real defect.** An absent required status reads as "not reviewed yet", which
|
||||
is indistinguishable from the correct pending state — so an ordinary PR looked ordinary while the gate
|
||||
was dead, and the cost landed only where no human was in the loop. PR #739 (docs-only) merged
|
||||
2026-08-05 with **zero** commit statuses on its head, and got in only because admin force-merge was
|
||||
still enabled; ersatztv#743 removed that escape the next day, so a docs-only or Renovate-manifest PR
|
||||
arriving after that would simply have been stuck with no bypass. The two Renovate PRs in the window
|
||||
escaped by timing, merging minutes before the bad commit.
|
||||
|
||||
Three things now hold the line, and they are deliberately different in kind:
|
||||
|
||||
- **The prose names expressions instead of quoting them** — write "a
|
||||
`github.event.pull_request.number` expression", not the delimiters. Pass values in through the
|
||||
step's `env:` block, which is interpolated per value, so a bad payload there cannot take the body
|
||||
with it.
|
||||
- **A start-marker guard turns a dropped step RED.** The classifier writes a marker as its first act
|
||||
and an `if: always()` step fails the job when it is missing. It asserts execution *started*, never
|
||||
that it completed — the classifier has several legitimate `exit 0` abstention paths. The guard's own
|
||||
body must stay expression-free, or the mechanism it guards against can delete the guard too, and
|
||||
that absence would be silent as well.
|
||||
- **Two static guards**, in `scripts/tests/test_pr_changed_files.py`: no delimiter in *any* `run:`
|
||||
body of this file (absolute — a dropped step here is a dead merge gate, and its bodies are ~700
|
||||
lines of prose), and repo-wide, every expression payload's **head token** must name a context or
|
||||
function the runner can resolve (permissive, because the other workflows interpolate into `run:`
|
||||
legitimately — 5 occurrences today, in `ci-image.yml`, `docker-build.yml`'s `api-docs`/`format`
|
||||
and `pr-checks.yml`'s two git-diff gates; #756 removed `build`'s two and banned that job as
|
||||
well, so the ban now covers `test`, `migrations` and `build`). Be precise about the second
|
||||
one's reach: it catches the
|
||||
historical defect (`pr number`) and a nonexistent context, but **not** a syntactically invalid
|
||||
payload whose tokens are all known (`${{ github.ref == }}` passes), nor a renamed output
|
||||
(`steps.metadata.outputs.shortsha` passes — every token after the first is preceded by `.` and is
|
||||
skipped), nor an unclosed opener. Catching those needs an expression parser. An earlier draft of
|
||||
this section claimed it caught "a payload that cannot evaluate, wherever it sits"; that was false,
|
||||
and the corrected claim is the one to rely on.
|
||||
|
||||
Worth knowing why nothing caught this for three days: every *other* workflow-shape test in that file
|
||||
reads `_code_lines()`, which strips comments. That is correct for what it was for, but it encodes the
|
||||
assumption this bug falsifies. The strict test reads the raw scalar, and must never adopt
|
||||
`_code_lines`.
|
||||
|
||||
⚠️ **A page past the end of `/issues/{n}/timeline` is JSON `null`, not `[]`** — and this instance is
|
||||
not consistent between endpoints (`/issues/{n}/comments` returns `[]` when empty). The retarget
|
||||
fence's `count_retargets` gated on `type == "array"`, so it read the real terminator as *unreadable*:
|
||||
the walk never reached a validated empty page, `rt_ok` was never `yes` for **any** PR, and the fence
|
||||
therefore withheld **every** exemption `success`. Renovate and docs-only PRs got no status at all —
|
||||
the same user-visible outcome as the dropped step above, by a completely unrelated route. So fixing
|
||||
the interpolation alone would not have restored the exemptions.
|
||||
|
||||
Two things kept it invisible, and both are worth generalising:
|
||||
|
||||
- It shipped in the **same commit** (8f6d4f443) that stopped the step executing, so the fence had
|
||||
never once run in production. A guard's first real execution is not the same event as its merge.
|
||||
- The **test double asserted the wrong shape while claiming measured fidelity.** Its comment read
|
||||
"Real shapes, measured on this instance and deliberately mirrored" and it printed `[]` for a page
|
||||
past the end. Every fence test was green against a response the server never produces, so the
|
||||
`array`-only gate was never exercised by the suite either. With the double corrected and the old
|
||||
gate restored, **most of the fence suite fails** — 18 tests when first measured at `c710db4a1`, 21
|
||||
once three more fence-dependent tests existed. The invariant is the point, not the count: they had
|
||||
all been passing for the wrong reason. (Given as a range on purpose — an earlier draft cited a bare
|
||||
"18", which was stale two commits later, inside a section about stale claims.) When a double claims
|
||||
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.
|
||||
`null` 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 an earlier draft's five figures were stale within days), so a `null` first
|
||||
page is anomalous rather
|
||||
than empty, and the walk should not certify "no retarget happened" from a response it cannot explain.
|
||||
|
||||
**The same nil-slice shape bites `/commits/{sha}/status`** — a third instance, found by cold review of
|
||||
the fix for the second. A head with no statuses yet returns
|
||||
`{"state":"pending","total_count":0,"statuses":null}` (measured on PR #739's head). `read_existing_verdict`
|
||||
gated on `.statuses | type == "array"`, so it hit its `exit 1` and posted nothing at all — fail-closed,
|
||||
same user-visible outcome. `null` is now accepted there only when `total_count` is 0, so a body that
|
||||
merely lost its array is still refused and an existing verdict is still protected from a transient
|
||||
error. `scripts/pr-changed-files.sh` was swept and is unaffected (`pulls/{n}/files` returns `[]`).
|
||||
**The generalisable rule: a nil Go slice serialises to `null`, so every list-shaped field on this API
|
||||
is suspect and only a per-endpoint measurement settles it.**
|
||||
|
||||
**Establishing that "no verdict exists" needs a second page, and both arithmetic guards for it are
|
||||
no-ops here.** `read_existing_verdict` concluding absence is what licenses posting an exemption over a
|
||||
verdict the job cannot see, so that conclusion has to be earned. Two obvious checks were tried and both
|
||||
proved empty:
|
||||
|
||||
- **`.statuses | length` vs `.total_count`** — `total_count` is the count for the **page returned**, not
|
||||
for the commit. Measured at 1.27.1 on `3aed43c6` (6 contexts): `?limit=1` returns
|
||||
`len=1, total_count=1`, `?limit=3` returns `len=3, total_count=3`. Equal by construction, so the check
|
||||
reads as a completeness proof while proving nothing.
|
||||
- **"refuse when the page comes back full at the requested `limit=100`"** — this instance caps `limit`
|
||||
at the server-wide `MAX_RESPONSE_ITEMS`, **measured at 50** (`/issues?limit=100` returns 50). A
|
||||
response can therefore never carry 100 rows, and the comparison was **dead code**. The repo already
|
||||
documented that cap in `scripts/pr-changed-files.sh`, two test files and `ci.script-tests-job`; the
|
||||
guard was written against 100 anyway, and a cold review caught it. Hardcoding 50 instead would
|
||||
re-break the day the setting changes.
|
||||
|
||||
So the job **asks the server, and only when it matters**: if the `review-verdict/h10` row is on page 1
|
||||
there is nothing further to learn (this endpoint returns the latest status per *context*, and a context
|
||||
cannot recur on a later page). When the row is absent it reads **page 2** — any rows there mean the list
|
||||
runs longer than one page and a verdict could be beyond it, so it refuses instead of concluding absence.
|
||||
Cap-independent by construction. Paging is real here: measured `?limit=3&page=2` returning three further
|
||||
rows, and `page=9` returning the same `statuses: null` terminator.
|
||||
|
||||
The `total_count` zero-check also requires the JSON **type** to be a number: `jq -r` renders `0` and
|
||||
`"0"` identically, so a text compare would accept a schema-corrupted `"total_count": "0"` as "no
|
||||
statuses".
|
||||
|
||||
**The repo-wide expression guard scans PARSED scalars, not raw file text.** A delimiter in an ordinary
|
||||
top-level YAML comment is inert — the runner never evaluates it — so redding on it is a false positive,
|
||||
and this file has now produced that false red twice. PyYAML drops those comments. A `run:` body is
|
||||
itself a scalar and keeps its *shell* comments, which is the point: inside a `run:` scalar a comment is
|
||||
not inert. Verified both directions by mutation — an inert top-level comment passes; the same payload
|
||||
in a run-body comment still reds.
|
||||
|
||||
**`CLAUDE.md` and `AGENTS.md` are now PROTECTED paths.** `DOCS_ONLY` matched them, so the documents
|
||||
that *define* the completion protocol, the merge-consent convention and the H10 rule were themselves
|
||||
docs-only-exemptible while `.claude/` was protected — the same self-exemption the gate rules out, one
|
||||
directory over. Driving the real classify body with a lone `CLAUDE.md` change produced
|
||||
`review-verdict/h10=success`. It is fixed here rather than deferred because restoring the exemptions is
|
||||
what makes it reachable: no exemption `success` was writable at all while the classify step was
|
||||
dropped. `README.md` is deliberately not listed — ordinary prose, no enforcement. For the same reason,
|
||||
#706's known residual returns with the working fence: while `rt_ok` was never `yes`, route 1 was closed
|
||||
by accident.
|
||||
|
||||
**That gap is now closed** — `docker-build.yml`'s `test` and `migrations` jobs are also required
|
||||
contexts, and there a dropped step is **fail-OPEN**: the required check goes green having done no work,
|
||||
which is strictly worse than an absent status (compare #684). ersatztv#756 gave those two jobs
|
||||
per-**step** execution markers and extended the delimiter ban to them; see
|
||||
"Dropped-step guard on the required jobs" above.
|
||||
|
||||
It lives in its **own workflow file** on purpose: `pr-checks.yml` sets `cancel-in-progress: true`,
|
||||
and a cancelled run there would leave an exempt PR with no status and no further push to
|
||||
|
||||
@@ -36,6 +36,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `blazor.rollback-tag` | The commit immediately preceding the Blazor-removal merge is tagged `blazor-final` (not a `v*` tag, so it doesn't trigger a prod release build) as the documented rollback/restore path. | 2026-07-11 | [link](records/blazor/rollback-tag.md) |
|
||||
| `blazor.ui-removed` | The legacy Blazor Server UI (`Pages/`, `Shared/`, `ViewModels/`, `Validators/`, MudBlazor + 8 other packages, Blazor Startup wiring) is fully deleted now that the SPA has parity; the legacy `MapWhen` branch is kept only for controllers/docs/OpenAPI/`LegacyUiRedirects`, and the catch-all fallback 302s any unmatched non-api/artwork/docs/openapi path to `/app`. | 2026-07-11 | [link](records/blazor/ui-removed.md) |
|
||||
| `channel.origin-marker` | A new `Channel.Origin` (`ChannelOrigin` enum — `Unknown`/`UserCreated`/`AutoTuned`) records how a channel row was created and is stamped exactly once at insert (`AutoTuned` in `CreateChannelFromLineupHandler`, `UserCreated` in `CreateChannelHandler`), and is never mutated on a later edit. It is surfaced as a raw `origin` field on `ChannelResponseModel`; the SPA badges only `AutoTuned`. Rows predating the column read `Unknown` — provenance is **not** back-filled. | 2026-07-23 | [link](records/channel/origin-marker.md) |
|
||||
| `ci.actions-credential-scoping` | Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. Do NOT add a `permissions:` key to constrain the injected `GITEA_TOKEN` on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP, which is worse than absent because it reads in review as a constraint. That version precondition NO LONGER HOLDS: this instance was upgraded 1.25.4 -> 1.27.1 on 2026-08-05. What has NOT changed is that the consequence is unverified — whether `permissions:` is honored here, and what this instance's default Actions token permission is, were both left UNPROBED (there is still no API surface: `/api/v1/settings/actions` 404s at 1.27.1). Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter (`ci-image.yml`, tracked in #744 — deliberately NOT bundled here, because editing that file re-points `ci-image-pin` at the editing commit and reddens a blocking job). This record closes ONE route. It does not close the class, and four later sections say exactly what survives — read them before citing this record as a mitigation. | 2026-08-05 | [link](records/ci/actions-credential-scoping.md) |
|
||||
| `ci.batch-pushes-no-cancel-route` | Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes. | 2026-07-21 | [link](records/ci/batch-pushes-no-cancel-route.md) |
|
||||
| `ci.build-once-rejected` | CI build-once (a shared compile artifact across jobs) was implemented, measured, and rejected for a 40-85% wall-clock regression; keep the #420 cross-run tree-identity skip instead. | 2026-07-18 | [link](records/ci/build-once-rejected.md) |
|
||||
| `ci.cancelled-is-not-a-verdict` | Treat a `cancelled` conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor. | 2026-07-21 | [link](records/ci/cancelled-is-not-a-verdict.md) |
|
||||
@@ -43,10 +44,10 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `ci.decisions-lifecycle-flake` | When `decisions lifecycle` is the **only** red job, do not investigate and do not create a new run to clear it — no rebase, no `--amend`, no no-op push; the operator reruns that single job from the Gitea UI. | 2026-07-21 | [link](records/ci/decisions-lifecycle-flake.md) |
|
||||
| `ci.docs-only-detect-shallow-safe` | The docs-only detect script must diff against `FETCH_HEAD` (always resolves after `git fetch`, even shallow) using a two-dot tree diff — not `origin/<base>` with three-dot — because a `fetch-depth: 1` shallow clone has no remote-tracking ref and no merge-base, which silently fails the original detect into `docs_only=false` (full matrix, no functional error). A CI-behavior change must be verified by measuring the effect (job durations), not just a green check. | 2026-07-17 | [link](records/ci/docs-only-detect-shallow-safe.md) |
|
||||
| `ci.docs-only-skip-steps` | A docs-only change must still run every required job (`test`, `migrations`) so their commit-status contexts always report; each heavy job runs `scripts/ci-detect-docs-only.sh` first and gates its real STEPS on `if: steps.detect.outputs.docs_only != 'true'`, never `if:`-skips the whole job (an `if:`-skipped job reports `skipped`, not `success`, which branch protection may never unblock on). Detection biases toward running more on any doubt. | 2026-07-17 | [link](records/ci/docs-only-skip-steps.md) |
|
||||
| `ci.exemption-provenance` | 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 only for a status POSITIVELY identified as a human verdict for THIS base, meaning a non-null `.creator.login` 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 — 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 only the sub-round-trip window that no API without compare-and-set can close. The PROTECTED path list additionally 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. 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`. | 2026-07-29 | [link](records/ci/exemption-provenance.md) |
|
||||
| `ci.exemption-provenance` | 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 only for a status POSITIVELY identified as a human verdict for THIS base, meaning a non-null `.creator.login` 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 — 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 only the sub-round-trip window that no API without compare-and-set can close. 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`. | 2026-07-29 | [link](records/ci/exemption-provenance.md) |
|
||||
| `ci.format-gate-folder-mode` | The blocking `format` CI job (and matching pre-commit hook) runs `dotnet format whitespace . --folder --include <files>` instead of loading the full MSBuild/Roslyn solution, cutting the gate from ~480s to ~0.5s with unchanged whitespace/charset coverage. | 2026-07-19 | [link](records/ci/format-gate-folder-mode.md) |
|
||||
| `ci.functional-e2e-harness` | The `functional-e2e` CI job boots the PR's own code from source via `dotnet run` (`scripts/e2e-local.sh`) and runs deterministic assertions (`scripts/e2e-functional.sh`) as an advisory (non-blocking) job, not a `build` dependency or required check. Originally curl-only; since #445 the same job carries a second, headless-browser step for the contracts curl cannot express — see `ci.ui-e2e-harness`. | 2026-07-16 | [link](records/ci/functional-e2e-harness.md) |
|
||||
| `ci.gate-trigger-base-resolved` | The workflow that writes the branch-protection-required `review-verdict/h10` status triggers on `pull_request_target` with `branches: [main]`, never on plain `pull_request`. Gitea resolves a `pull_request` workflow DEFINITION from the PR's own head commit, so under that trigger a PR editing `.gitea/workflows/review-verdict.yml` ran its own rewritten copy and could post `h10=success` for itself; `pull_request_target` resolves the definition from the base instead. The `branches: [main]` filter is part of the rule, not a refinement of it: base resolution only relocates the rewrite from the head to the base, so without the filter a PR opened into an attacker-pushed base branch runs that branch's gate. `pull_request_target` is safe HERE only because this job never checks out or executes head-supplied code — it checks out `base.sha` and runs only that tree's scripts (`ci.shared-pr-file-enumeration`); reintroducing a head checkout under this trigger would be worse than the bug it fixed. This closes the rewrite route through THIS workflow and does NOT close the class: Gitea injects a write-capable `GITEA_TOKEN` into EVERY job, so any ref-resolved workflow — and a collaborator's own API token, since branch protection binds the context and not its issuer — can still forge `review-verdict/h10`. Tracked in #697; the exemption path has its own separate defects in #698. | 2026-07-28 | [link](records/ci/gate-trigger-base-resolved.md) |
|
||||
| `ci.gate-trigger-base-resolved` | The workflow that writes the branch-protection-required `review-verdict/h10` status triggers on `pull_request_target` with `branches: [main]`, never on plain `pull_request`. Gitea resolves a `pull_request` workflow DEFINITION from the PR's own head commit, so under that trigger a PR editing `.gitea/workflows/review-verdict.yml` ran its own rewritten copy and could post `h10=success` for itself; `pull_request_target` resolves the definition from the base instead. The `branches: [main]` filter is part of the rule, not a refinement of it: base resolution only relocates the rewrite from the head to the base, so without the filter a PR opened into an attacker-pushed base branch runs that branch's gate. `pull_request_target` is safe HERE only because this job never checks out or executes head-supplied code — it checks out `base.sha` and runs only that tree's scripts (`ci.shared-pr-file-enumeration`); reintroducing a head checkout under this trigger would be worse than the bug it fixed. This closes the rewrite route through THIS workflow and does NOT close the class: Gitea injects a write-capable `GITEA_TOKEN` into EVERY job, so any ref-resolved workflow — and a collaborator's own API token, since branch protection binds the context and not its issuer — can still forge `review-verdict/h10`. The credential half is now RESOLVED in `ci.actions-credential-scoping` (#697): CI's registry secret was the ADMIN account's basic auth and is now a PAT that cannot post a status, which removes the ADMIN escalation and that credential's route (a user credential's forgery carries a real `creator` and is inherited as a human verdict; an Actions job's carries `creator: null` and is re-derived — but do NOT read that asymmetry as protection: re-derivation fires only on the trigger's `types`, and posting a status is not one of them, so a POST timed after the last PR event simply stands). It does not remove EVERY route: `RENOVATE_TOKEN` is a `write:repository` bot PAT in the same secret store, reachable by any PR-added workflow. The injected token stays write-capable until Gitea >=1.26 with a Restricted default (server-management#714), and a collaborator's own token remains unfixable; the exemption path has its own separate defects in #698. | 2026-07-28 | [link](records/ci/gate-trigger-base-resolved.md) |
|
||||
| `ci.gitea-milestone-filter-noop` | Never filter issues with the server-side `?milestones=<name>` parameter — fetch all open issues once and filter LOCALLY on each issue's `.milestone.title`. | 2026-07-21 | [link](records/ci/gitea-milestone-filter-noop.md) |
|
||||
| `ci.grep-q-pipefail-inversion` | In any script running under `set -o pipefail`, a security or classification predicate of the form `producer \| grep -q…` is FORBIDDEN: `grep -q` exits at its first match, the producer then takes SIGPIPE and exits 141 once the data exceeds the pipe buffer (~64K), so `pipefail` reports the pipeline as FAILED even though grep MATCHED — inverting the predicate exactly when the input is large. A here-string (`grep -q… <<< "$data"`) is ALSO forbidden: bash materialises a large here-string via temporary storage, so it fails when temp space is full or unwritable, and inside an `if`/`!` that failure flips the predicate the same way. COUNT instead — `n=$(printf '%s\n' "$data" \| grep -cE "$re")` — because `grep -c` drains stdin (no early exit, no SIGPIPE) over an ordinary pipe (no temp file). Read grep's status honestly: exit 1 means a zero count and is a legitimate answer, anything >1 is a real error. Evaluate the counts ONCE at TOP LEVEL, never inline inside an `if`/`elif` condition: inside `$( )` an `exit` leaves only the subshell and `set -e` does not fire, so an error silently reads as "no match". Validate that each result is numeric and fail closed if not. This applies to both the enforced gate `.gitea/workflows/review-verdict.yml` and the advisory hook `.claude/hooks/pretooluse-merge-consent.sh`. | 2026-07-29 | [link](records/ci/grep-q-pipefail-inversion.md) |
|
||||
| `ci.infra-shaped-red-under-load` | When a job dies inside a setup/cache step before your code compiles, check the runner host's load before diagnosing the diff, and never file a CI bug off one sample under pressure. | 2026-07-21 | [link](records/ci/infra-shaped-red-under-load.md) |
|
||||
@@ -55,6 +56,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `ci.monitor-armed-at-pr-open` | Arm a CI monitor on the PR head sha the moment the PR opens, polling the commit-status endpoint — not at the end of the work. | 2026-07-21 | [link](records/ci/monitor-armed-at-pr-open.md) |
|
||||
| `ci.no-host-health-gating` | Push when your work is validated — never SSH to bumblebee to sample load/RAM first, and never hand-schedule around other sessions' runs. | 2026-07-21 | [link](records/ci/no-host-health-gating.md) |
|
||||
| `ci.peak-anon-measurement` | The `test` job's headline memory figure is a sampled high-water mark of cgroup `anon`, produced by `scripts/ci-peak-anon.sh`; `memory.peak` and the end-of-job `anon`/`file` split are kept only as a cache-inflated reference. | 2026-07-19 | [link](records/ci/peak-anon-measurement.md) |
|
||||
| `ci.required-job-step-execution-markers` | A step the runner declines to interpolate is DROPPED and the job still concludes `success` (`ci.workflow-run-body-no-expressions`). In `review-verdict.yml` that is fail-CLOSED — the required status is absent and the merge is blocked. In `docker-build.yml`'s `test` and `migrations` it is fail-OPEN: those are the other two required contexts on `main`, so the check reports green having done no work. So in those two jobs every `run:` step that is not `continue-on-error: true` calls `"$GITHUB_WORKSPACE/scripts/ci-step-ran.sh" mark <key>` as its FIRST act, and the job's LAST step calls `ci-step-ran.sh assert --always <keys> --gated <keys>`, which fails the job when an expected key was never recorded. PER STEP, not per job: a marker written by the first step only proves the job started, while the drop that costs something is `Test` or the migration replay. The guard carries NO `if:` — the default `success()` is the wanted condition, because a genuine failure in an early step legitimately skips every later one and an `always()` guard would announce a false "these steps never executed" on every ordinary red build; the invariant that makes the omission safe is that the guard is skipped only when an earlier step FAILED, which already fails the job, so guard-skipped implies job-red and every path to a green job runs the guard. Separately and independently, no `${{` OPENER may appear in any `run:` body of those two jobs OR of `build` — the drop mechanism requires the opener, so banning it makes the class unreachable rather than merely caught, and an UNCLOSED opener triggers the same rewrite as a well-formed pair. Pass values in through the step's `env:`, which is interpolated per value. The two halves have DIFFERENT scopes on purpose: markers cover the required pair, while the ban also covers `build`, whose `Smoke + IPTV E2E` step runs AFTER the image is pushed, so a drop there publishes a release candidate that was never booted and that `DeployStack jazz-media` then promotes. `functional-e2e` is delimiter-free but deliberately excluded (advisory by declaration), and `api-docs`/`format` keep one `github.base_ref` each and gate nothing that ships. The ban is enforced on the RELEASE PATH itself, not only in review (#767): a `scan` job runs the PyYAML-based ban test and `build` lists it in `needs:`, so a delimiter means `build` never runs and no image is published. A guard STEP inside `build` was tried first and is wrong — a step cannot protect the job it publishes from, and "my body has no opener so I cannot be dropped" is circular when only the PR-only test enforces that. The pytest in `script-tests` remains, but it is `on: pull_request` and not a required context, so it alone left the tag path unchecked. | 2026-08-10 | [link](records/ci/required-job-step-execution-markers.md) |
|
||||
| `ci.root-screenshot-guard` | The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected. | 2026-07-12 | [link](records/ci/root-screenshot-guard.md) |
|
||||
| `ci.runner-placement` | No persistent Roslyn compiler server survives a CI build (`UseSharedCompilation=false` etc., runner env + Dockerfile `ENV`); every `services:` container gets its own explicit `--memory`/`--memory-swap`/`--cpus` cap (it does not inherit the job container's). | 2026-07-17 | [link](records/ci/runner-placement.md) |
|
||||
| `ci.script-tests-job` | The `scripts/tests/` pytest suite runs on every PR as a dedicated `script-tests` job in `pr-checks.yml` (`runs-on: small`, `setup-python` + `pip install pytest`, `PYTHONPATH=. python3 -m pytest scripts/tests -q`), unconditionally rather than behind a `scripts/**` path filter, and **never as a step inside `decisions-guard`** — a job whose reds a standing rule instructs sessions to ignore must never host a gate whose reds are real. Any new CI gate must be reachable by a failure that is unambiguously attributable to it. | 2026-07-26 | [link](records/ci/script-tests-job.md) |
|
||||
@@ -64,6 +66,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `ci.verdict-write-retarget-fence` | The `review-verdict/h10` job counts `change_target_branch` events on the PR's issue timeline at run start and again immediately before its POST, and writes NOTHING if the count moved. The COUNT is the key because the branch NAME is ABA-vulnerable — `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a forged exemption — while the event count is monotonic and cannot alias. Abstaining is a handoff, not a stall, and that is the property the design rests on: every retarget fires `edited`, which is in this workflow's `types:`, so the event that makes a run abstain has already queued a successor whose window opens after it; the induction terminates when retargeting stops and the last run writes the final answer. `updated_at` was REJECTED as the key because it also moves for comments and labels, which fire none of this workflow's `types:` — a run could abstain with no successor coming, which is a real stall. The count is trusted only when paging reached a validated EMPTY page; an untrusted count (unreadable page, non-array body, non-numeric length, page cap hit) blocks the exemption `success` ONLY and still lets `pending` through, because `pending` cannot turn an unreviewed head green while withholding it would strand ordinary PRs for no safety gain. SEPARATELY, and for the human-verdict race the fence does nothing about: after posting an exemption `success` the job re-reads `/statuses/{sha}` and, if a human `Review-verdict:` row appeared with an id ABOVE a high-water mark taken just before the POST, overwrites its own status with `pending` and logs an error. The repair is `pending`, NEVER a copy of the human's state, since re-posting their `failure` under the machine credential would attribute a human verdict to the job; its description is a SENTINEL that the classification refuses to grant an exemption over AND re-writes verbatim on every later run, so the block is a FIXED POINT rather than decaying — writing the generic `pending` description there instead erases the marker and the exemption simply returns one event later. The mark is captured BEFORE the last-moment re-read, not merely before the POST — a later mark leaves a multi-round-trip blind gap in which a verdict is neither seen by the re-read nor repaired afterwards. The id comparison is load-bearing: a mere presence test would fire forever on a base-mismatched verdict that `read_existing_verdict` deliberately declines to honour, deadlocking that PR's exemption permanently. Finally, a run whose last-moment re-read finds a sentinel it did not see at its FIRST read ABSTAINS instead of posting: that can only mean an overlapping run repaired a raced verdict mid-flight, and this run's `success` — frozen at classification time, with the human row below its own mark, so neither the fence nor the post-write check would catch it — would otherwise bury the rejection. That is the one path in this design that failed toward SUCCESS rather than `pending`. The post-write check counts TWO row shapes above the mark, not one — a human `Review-verdict:` row AND a machine sentinel — because with two overlapping runs the human row can sit BELOW the second run's mark while the first masks it and only then writes the sentinel, leaving the second to post its own `success` on top; counting the sentinel converges both runs on the fixed point instead. | 2026-08-03 | [link](records/ci/verdict-write-retarget-fence.md) |
|
||||
| `ci.verify-locally-ci-confirms` | Treat the local build/verify/review pass as the decision point and CI as confirmation — don't idle waiting on a run you have no reason to doubt. | 2026-07-21 | [link](records/ci/verify-locally-ci-confirms.md) |
|
||||
| `ci.web-test-per-test-timeouts` | Give heavy-render web tests an explicit per-test vitest timeout (e.g. 15s); never raise the global default to fix one slow test. | 2026-07-21 | [link](records/ci/web-test-per-test-timeouts.md) |
|
||||
| `ci.workflow-run-body-no-expressions` | A `run:` body is not shell when the runner reads it: the runner scans the whole scalar for the expression opener and, on finding one, rewrites the ENTIRE body into a single `format(...)` call. That rewrite is all-or-nothing, so a payload that does not evaluate fails the interpolation of the whole scalar — and the runner then DROPS THE STEP AND CONCLUDES THE JOB `success`. A shell comment is therefore NOT inert. In `.gitea/workflows/review-verdict.yml` no expression delimiter may appear in ANY `run:` body, in code or in prose, because a dropped step there is a dead merge gate rather than a failed build; pass values in through the step's `env:` block, which is interpolated per value so a bad payload cannot take the body with it, and describe an expression in prose by NAMING it (`a github.event.pull_request.number expression`) rather than quoting the delimiters. Repo-wide the rule is weaker and its reach must be stated precisely rather than generously: every expression payload in every workflow field must have a HEAD TOKEN naming a context or function the runner can resolve. That catches the defect above and a nonexistent context; it does NOT catch a syntactically invalid payload whose tokens are all known (`${{ github.ref == }}`), a renamed output (every token after the first is skipped), or an unclosed opener — those need an expression parser, and the guard is kept permissive on purpose because a red here blocks every merge through the combined status. In `review-verdict.yml` specifically, any step whose non-execution is consequential is paired with a start-marker guard that FAILS the job when the marker is absent, and that guard's own body must be expression-free — a guard the guarded mechanism can silently delete is worse than none. That pairing now also covers `docker-build.yml`'s `test` and `migrations` jobs, where a dropped step is fail-OPEN (the required check goes green having done no work) rather than fail-closed as it is here — see `ci.required-job-step-execution-markers`, which adds per-STEP markers there and extends this file's delimiter ban to those two jobs. It is still not a repo-wide property, but the remaining exceptions are narrower than this record originally said: `build` was brought into the ban too (its `Smoke + IPTV E2E` runs AFTER the image is pushed, so a drop there ships an unsmoked release candidate — its two payloads moved to `env:`, so the ban was free), leaving only `api-docs` and `format`, whose one `github.base_ref` each sits in a detect step that gates nothing that ships. | 2026-08-06 | [link](records/ci/workflow-run-body-no-expressions.md) |
|
||||
| `concurrency.diff-scalar-fanout` | The frozen Block optimistic-concurrency recipe (api-conventions §7a) fans out to Collection/Playout×2/MultiCollection/RerunCollection, keeping a guard-returned `PreconditionFailedError` out of any handler's generic `catch(Exception)`→422 mapping, and preserving each aggregate's existing `SaveChangesAsync() > 0` gate semantics under the new unconditional `Version++`. | 2026-07-11 | [link](records/concurrency/diff-scalar-fanout.md) |
|
||||
| `concurrency.etag-rotation-completion` | Every handler that mutates a versioned root's editor-visible config state must bump `Version` (rotating the ETag) with no per-aggregate carve-outs, short-circuiting on a genuine no-op before the bump so idempotent re-submits don't fire spurious rebuild fan-out; `SaveChangesForcingVersion` rebases the retry (stored + pending delta), never adopts the stored token verbatim. | 2026-07-12 | [link](records/concurrency/etag-rotation-completion.md) |
|
||||
| `concurrency.force-write-non-ifmatch` | Any handler that leaves a versioned root `Modified` or `Deleted` but takes no `If-Match` (deletes, item add/remove bumpers, scalar-config writers) must save through `ConcurrencyExtensions.SaveChangesForcingVersion` — force-write past a concurrent `Version` bump rather than throw an unhandled `DbUpdateConcurrencyException` (500). | 2026-07-12 | [link](records/concurrency/force-write-non-ifmatch.md) |
|
||||
@@ -95,6 +98,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `iptv.logo-drives-bug-preset` | One uploaded channel logo drives both the listing logo and the on-screen bug via a shared, seeded `ChannelLogo`-sourced watermark preset (`Channel Bug`), not new per-channel schema. | 2026-07-20 | [link](records/iptv/logo-drives-bug-preset.md) |
|
||||
| `locking.entitylocker-atomic-flags` | `EntityLocker` uses `Interlocked.CompareExchange`-guarded atomic flags plus a documented single-owner-release discipline (no owner tokens/leases); `Unlock*` on an already-unlocked slot returns `false` and logs a Warning rather than throwing. | 2026-07-11 | [link](records/locking/entitylocker-atomic-flags.md) |
|
||||
| `mcp.server-foundation` | `ErsatzTV.Mcp` is a fresh stdio JSON-RPC server wrapping frozen `/api/v1` with explicit narrow per-endpoint tools, read-only-by-default enforced at runtime (`ERSATZTV_ALLOW_WRITES`), machine-key auth, and opt-in `If-Match`. | 2026-07-20 | [link](records/mcp/server-foundation.md) |
|
||||
| `mcp.tool-schema-openapi-parity` | Every POST/PUT/PATCH tool in `ToolCatalog` declares exactly the request-body properties its endpoint accepts, each with a matching type, and EVERY tool (read and write) declares exactly its endpoint's query parameters, both asserted against the generated `ErsatzTV/wwwroot/openapi/v1.json` (linked into `ErsatzTV.Mcp.Tests`) by `Every_Write_Tool_Should_Declare_Exactly_Its_OpenApi_Request_Body_Fields` and `Every_Tool_Should_Declare_Exactly_Its_OpenApi_Query_Parameters`. A field the endpoint accepts but the tool omits is a DEFECT, not a deferral: on the full-replace tools (channel update, schedule update, custom-order) the omission is silently applied as a clear. The write tools are NOT uniformly full-replace — add-collection-items is additive, and several leave an omitted field unchanged — so each tool description states its own semantics. An omitted query parameter is UNREACHABLE, not merely undocumented, because `ToolArgumentValidator` rejects undeclared arguments. | 2026-08-06 | [link](records/mcp/tool-schema-openapi-parity.md) |
|
||||
| `media.lastscan-null-boundary` | A never-scanned `LastScan` surfaces as `null` at the API/MCP boundary, not the `0001-01-01` MinValue sentinel — enforced by an ongoing read-boundary coercion plus a one-time data migration cleanup. | 2026-07-18 | [link](records/media/lastscan-null-boundary.md) |
|
||||
| `media.remote-stream-probe` | `ValidatePlayoutItemPath` probes the Plex/Jellyfin/Emby remote-stream URL via `IRemoteStreamProber` before returning it; only a redirected 404 fails closed (`PlayoutItemNotAvailableFromMediaServer`), everything else fails open, and there is no toggle. | 2026-07-19 | [link](records/media/remote-stream-probe.md) |
|
||||
| `media.remote-stream-probe-externaljson` | External-JSON playout channels' `StreamRemotely` now probes the remote-stream URL through the same `IRemoteStreamProber` seam as the generated-playout path, closing the #473 scope gap for a channel kind with no DB `PlayoutItem` rows. | 2026-07-20 | [link](records/media/remote-stream-probe-externaljson.md) |
|
||||
@@ -120,8 +124,9 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `process.subagent-drop-resume` | Treat a subagent connection drop as laptop sleep or transient network and re-resume via SendMessage — the work survives. | 2026-07-21 | [link](records/process/subagent-drop-resume.md) |
|
||||
| `release.api-contract-ci-gate` | A PR touching `ErsatzTV/Controllers/Api/**` or `ErsatzTV.Core/Api/**` must ship regenerated OpenAPI artifacts (`v1.json`, `v1.d.ts`, `endpoint-index.md`) in the same diff, enforced by a blocking `api-docs` CI job that regenerates-and-diffs against a fresh build. | 2026-07-12 | [link](records/release/api-contract-ci-gate.md) |
|
||||
| `release.done-when-merge-consent` | A PR may merge only when its linked issue's `## Done-when` checklist is fully ticked and the PR's CI is green, enforced by a PreToolUse hook on the Gitea merge tool (deny/allow/ask) plus a pre-push backstop for direct pushes to main. | 2026-07-12 | [link](records/release/done-when-merge-consent.md) |
|
||||
| `release.format-as-you-touch-rebase` | A blocking `format` CI job runs `dotnet format --verify-no-changes` scoped only to the PR's changed `.cs` files (never the legacy BOM backlog), and a PR branch must be kept current by rebasing on `origin/main` (never merging main in), enforced by `.husky/pre-push` → `prepush-rebase-check.sh`. | 2026-07-12 | [link](records/release/format-as-you-touch-rebase.md) |
|
||||
| `release.format-as-you-touch-rebase` | A blocking `format` CI job runs `dotnet format --verify-no-changes` scoped only to the PR's changed `.cs` files (never the legacy BOM backlog), and a PR branch must be kept current by rebasing on `origin/main` (never merging main in), enforced by `.husky/pre-push` → `prepush-rebase-check.sh`. H11 has ONE always-on carve-out, #719 — a push in which EVERY ref is under `refs/tags/` skips the freshness check, because a tag push cannot revert merged work, which is the failure mode H11 exists to prevent, and the release cut tags from a branch that is behind `origin/main` (observed on the v26.13.0 cut, #719). A push mixing branch and tag refs is still blocked, and so is a push with zero parsed ref lines (the exemption requires at least one, so empty stdin cannot vacuously disable H11). | 2026-07-12 | [link](records/release/format-as-you-touch-rebase.md) |
|
||||
| `release.live-e2e-required` | A PR that changes an API write-path handler must include a live-E2E pass (driving the real endpoint/screen and confirming the round-trip through a subsequent read), not only unit/characterization tests, and must state whether live-E2E ran or wasn't required. | 2026-07-12 | [link](records/release/live-e2e-required.md) |
|
||||
| `release.main-direct-push-disabled` | Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are unaffected (`tag_protections` governs those separately), so the release cut still works. | 2026-08-05 | [link](records/release/main-direct-push-disabled.md) |
|
||||
| `release.merge-consent-autogrant` | When Done-when boxes are ticked, CI is green, and a fresh positive Review-verdict references head, the merge-consent hook emits `permissionDecision: allow` to actually suppress the redundant mechanical prompt — the derived state IS the consent, no separate conversational confirmation on that path. | 2026-07-12 | [link](records/release/merge-consent-autogrant.md) |
|
||||
| `release.migration-rehearsal-prodcopy` | Before promoting a migration-bearing release, rehearse the new image's migrations against a throwaway copy of the latest prod backup (`scripts/migration-smoke.sh`), gating PASS on the migrator's completion log line rather than HTTP readiness alone. | 2026-07-12 | [link](records/release/migration-rehearsal-prodcopy.md) |
|
||||
| `release.prepush-clean-worktree-guard` | A fail-open pre-push hook blocks a push when any file in the branch's diff vs `origin/main` also has uncommitted working-tree or index changes, since a stale-index commit (e.g. `git reset --soft` + `git add` over an edited-but-unstaged fix) can silently push, CI-test, and get reviewed a different tree than the one on disk. Scope is precise to pushed-diff files; escape hatch `ETV_ALLOW_DIRTY_PUSH=1`. | 2026-07-17 | [link](records/release/prepush-clean-worktree-guard.md) |
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
key: ci.actions-credential-scoping
|
||||
title: '2026-08-05 — CI''s registry credential is a scoped PAT, not the admin password, because Gitea cannot separate status-write from repo-write (#697)'
|
||||
status: active
|
||||
since: '2026-08-05'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. Do NOT add a `permissions:` key to constrain the injected `GITEA_TOKEN` on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP, which is worse than absent because it reads in review as a constraint. That version precondition NO LONGER HOLDS: this instance was upgraded 1.25.4 -> 1.27.1 on 2026-08-05. What has NOT changed is that the consequence is unverified — whether `permissions:` is honored here, and what this instance''s default Actions token permission is, were both left UNPROBED (there is still no API surface: `/api/v1/settings/actions` 404s at 1.27.1). Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter (`ci-image.yml`, tracked in #744 — deliberately NOT bundled here, because editing that file re-points `ci-image-pin` at the editing commit and reddens a blocking job). This record closes ONE route. It does not close the class, and four later sections say exactly what survives — read them before citing this record as a mitigation.'
|
||||
signals: 'admin password in CI secrets, registry credential scope, ETV_STATUS_AUTH can write statuses, forge review-verdict/h10, head-resolved workflow holds credentials, Gitea token scopes, no status scope, write:package vs write:repository, permissions key no-op, GITEA_TOKEN default read/write, Restricted default token permissions, orphan secret, deploy key in secret store, toolchain image overwrite, prod floating tag write · paths: `.gitea/workflows/docker-build.yml`, `.gitea/workflows/ci-image.yml`, `.gitea/workflows/renovate.yml`, `scripts/ci-detect-already-validated.sh` · issues: #697, #672, #698, #742, #743, #420, server-management#714'
|
||||
mechanics: 'PAT `ci-registry-scoped-697`, scopes `write:package,read:repository`, stored as repo Actions secret `REGISTRY_PASSWORD`; `REGISTRY_USER` remains `timothy`. Verified 2026-08-05 on Gitea 1.25.4: registry push of a probe tag SUCCEEDED; `GET /commits/{sha}/status` 200; `POST /statuses/{sha}` REFUSED HTTP 403 `token does not have at least one of required scope(s), required=[write:repository], token scope=write:package,read:repository`. Probe artifacts deleted, confirmed 404. NOT measured with this token: the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea''s scope model (write implies read per category, read at tag `v1.25.4`) — INFERRED. Note WHICH run proves which: only the `container:` pull is exercised by a PR. `cache-to`/`cache-from` and the base-image pull are confined to the `build` job, which carries `if: github.event_name != ''pull_request''`, so they are first exercised on the post-merge push to `main` — AFTER the merge gate has passed. A wrong inference there reddens main, not the PR.'
|
||||
---
|
||||
|
||||
**What was wrong.** `REGISTRY_USER`/`REGISTRY_PASSWORD` were the **admin account's** basic auth, and
|
||||
`docker-build.yml` triggers on `pull_request` — head-resolved — so a PR's own code got instance-admin
|
||||
credentials. Basic auth carries no scope: the secret pushing an image administers every repo on the
|
||||
instance.
|
||||
|
||||
**Why the credential and not only the triggers.** Patching triggers enumerates *instances* of "a
|
||||
ref-resolved workflow obtains status-capable credentials", and adding a new workflow file is itself a
|
||||
route, so that enumeration never completes. But it is not either/or: `ci-image.yml`'s unfiltered
|
||||
`push:` is path-scoped to itself, so any branch push runs attacker YAML on a docker-capable runner
|
||||
with no PR. Scoping bounds what a job may DO; only a filter bounds whether it RUNS. That filter is
|
||||
**#744**, not this record: editing `ci-image.yml` re-points `ci-image-pin`'s `expected` at the editing
|
||||
commit and staleness-fails a **blocking** job. That is a toll, not a wall — the documented two-step
|
||||
(publish `:<short sha>`, then bump all five pins) clears it — but a rebase rewrites the sha and charges
|
||||
it again, so it lands alone (`land-toolchain-image-change-separately`).
|
||||
|
||||
**What the scoped token still reaches — not "just a registry credential".** `write:package` over owner
|
||||
`timothy` writes `ersatztv:prod` (the floating tag prod's `jazz-media` stack follows) and
|
||||
`ersatztv-ci:<sha>` (the toolchain image *executing* five `container:` jobs). A sha-named tag is not an
|
||||
immutable artifact (no container tag immutability in Gitea 1.25 — INFERRED), so overwriting the pinned
|
||||
tag is code execution inside CI, chaining back into the routes below. This is the deployment supply
|
||||
chain for prod and CI itself.
|
||||
|
||||
**Admin ownership is a real residual.** The PAT is minted under `timothy`, a site admin. The 403 proves
|
||||
the scope gate binds the *status* endpoint ahead of any admin bypass; it does NOT establish that for
|
||||
*package* endpoints, where Gitea resolves permission by owner and an admin passes object-level checks,
|
||||
so the token's package reach is plausibly wider than this repo. A non-admin bot account would close
|
||||
this, but is not free: packages live in a user namespace only its owner and admins can write. Both
|
||||
halves INFERRED, neither probed.
|
||||
|
||||
**Provenance, corrected.** `review-verdict.yml` leaves an existing `h10` alone only when it is
|
||||
positively identifiable as human — non-null `.creator.login` plus a `Review-verdict:` description
|
||||
(`release.verdict-status-check`). A user credential posts with a real creator and is INHERITED; an
|
||||
Actions job posts `creator: null` and is re-derived. **That asymmetry is not protection.** Re-derivation
|
||||
fires only on `opened|reopened|synchronize|ready_for_review|edited`, and posting a status is none of
|
||||
them, so a POST timed after the last event stands until the attacker merges. The gain here is that PR
|
||||
code can no longer escalate to instance admin — NOT that the durable forgery route is closed.
|
||||
|
||||
**The boundary is everything reachable from a job, not the secret store.** The store is a useful lower
|
||||
bound — auditing it rather than the workflow set is what found `RENOVATE_TOKEN` and
|
||||
`SERVERMGMT_DEPLOY_KEY` below, since any PR-added workflow can reference any secret. But
|
||||
`GITEA_TOKEN` is injected and never in the store; nor is the credential
|
||||
`actions/checkout` persists into `.git/config` (`docker-build.yml` omits `persist-credentials: false`);
|
||||
and jobs reach the runner's docker daemon.
|
||||
|
||||
**Measured vs inferred.** Measured here: the `v1.25.4` 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.
|
||||
|
||||
**Version caveat — this record's measurements are pinned to 1.25.4, the instance is now 1.27.1.**
|
||||
The instance was upgraded mid-session on 2026-08-05 (#743). Everything above measured on 1.25.4 is
|
||||
therefore a *dated* claim, not a current one: the scope enum, the `reqRepoWriter` gate and the 403
|
||||
probe were all taken pre-upgrade and have NOT been re-run. They are recorded honestly as of their
|
||||
date and are the best evidence available, but do not cite them as current behaviour without
|
||||
re-probing. Re-verification of the 1.25.4-pinned claims across the CI docs is tracked separately.
|
||||
|
||||
**Surviving routes — this record is not a mitigation for any of them.** `RENOVATE_TOKEN` is a
|
||||
`write:repository` bot PAT in the same store, posting with a real creator, and cannot be scoped down
|
||||
because Renovate needs repo write (#742). The injected `GITEA_TOKEN` is write-capable in every job;
|
||||
only Gitea >=1.26 with the Actions default set to **Restricted** binds it (server-management#714) —
|
||||
the version half of that condition is now satisfied (1.27.1) but the *default* half is unverified, so
|
||||
treat this route as still open until probed. A
|
||||
collaborator's own token always can. `docker-build.yml` publishes `:prod` from a `v*` tag push and a tag
|
||||
may point at ANY commit — a prod image with no PR, review or status (tag protections are empty).
|
||||
**And none of it was necessary: direct pushes to `main` were server-side permitted, so the gate was
|
||||
bypassable with no forgery at all (#743).** That route is now closed — `main` carries
|
||||
`enable_push: false` (`release.main-direct-push-disabled`), which removes `main` as a destination for
|
||||
every write-only credential in this list, including the injected `GITEA_TOKEN` and `RENOVATE_TOKEN`.
|
||||
It does not remove them as *forgery* routes on the PR path, and it does not bind an admin credential,
|
||||
which can PATCH the protection off first. Correction to this record's earlier wording: a push
|
||||
*whitelist* would NOT have closed more of the class than the upgrade — measured 2026-08-05, a
|
||||
whitelist naming `timothy` still admitted the push, and every credential here acts as `timothy`.
|
||||
Treat this list as "at least these", never exhaustive. `SERVERMGMT_DEPLOY_KEY` remains in the
|
||||
store though its `bump-prod-compose` job went in `1b5efd7b9`, and its key on `timothy/server-management`
|
||||
is `read_only: false` — write access to the repo holding prod's GitOps stack definitions. Left in place
|
||||
by explicit decision 2026-08-05; recorded so it is accepted, not forgotten. Severity throughout: push
|
||||
access required, so a compromised contributor or subverted automated session, never an anonymous one.
|
||||
@@ -5,7 +5,7 @@ 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 only for a status POSITIVELY identified as a human verdict for THIS base, meaning a non-null `.creator.login` 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 — 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 only the sub-round-trip window that no API without compare-and-set can close. The PROTECTED path list additionally 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. 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`.'
|
||||
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 only for a status POSITIVELY identified as a human verdict for THIS base, meaning a non-null `.creator.login` 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 — 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 only the sub-round-trip window that no API without compare-and-set can close. 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: '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: #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; short-circuit requires `.creator.login` non-null AND description matching `^Review-verdict:`; `types: [opened, reopened, synchronize, ready_for_review, edited]`'
|
||||
---
|
||||
@@ -79,9 +79,10 @@ test still fails: the guard degrades toward re-deriving, never toward trusting.
|
||||
|
||||
**What this does NOT close.** Anyone who can POST statuses directly can write both a creator and a
|
||||
`Review-verdict:` description and impersonate a verdict; branch protection binds the *context*, not its
|
||||
issuer. A provenance check, not an authentication one — that is `#697`, left open because its durable
|
||||
fix is credential scoping, partly server-management territory. Severity as `#672`: requires push
|
||||
access, so the threat model is a compromised contributor.
|
||||
issuer. A provenance check, not an authentication one — that was `#697`'s registry credential, fixed by
|
||||
scoping it off `write:repository` (`ci.actions-credential-scoping`); `GITEA_TOKEN`, `RENOVATE_TOKEN`,
|
||||
and a collaborator's own token still can. Severity as `#672`: requires push access, so the threat model
|
||||
is a compromised contributor.
|
||||
|
||||
**Verification honesty.** Route 1 was reproduced live; the "and now it fails" half cannot be shown from
|
||||
a PR, because `pull_request_target` resolves this definition from `main` — the self-test gap
|
||||
|
||||
@@ -5,7 +5,7 @@ status: active
|
||||
since: '2026-07-28'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'The workflow that writes the branch-protection-required `review-verdict/h10` status triggers on `pull_request_target` with `branches: [main]`, never on plain `pull_request`. Gitea resolves a `pull_request` workflow DEFINITION from the PR''s own head commit, so under that trigger a PR editing `.gitea/workflows/review-verdict.yml` ran its own rewritten copy and could post `h10=success` for itself; `pull_request_target` resolves the definition from the base instead. The `branches: [main]` filter is part of the rule, not a refinement of it: base resolution only relocates the rewrite from the head to the base, so without the filter a PR opened into an attacker-pushed base branch runs that branch''s gate. `pull_request_target` is safe HERE only because this job never checks out or executes head-supplied code — it checks out `base.sha` and runs only that tree''s scripts (`ci.shared-pr-file-enumeration`); reintroducing a head checkout under this trigger would be worse than the bug it fixed. This closes the rewrite route through THIS workflow and does NOT close the class: Gitea injects a write-capable `GITEA_TOKEN` into EVERY job, so any ref-resolved workflow — and a collaborator''s own API token, since branch protection binds the context and not its issuer — can still forge `review-verdict/h10`. Tracked in #697; the exemption path has its own separate defects in #698.'
|
||||
rule: 'The workflow that writes the branch-protection-required `review-verdict/h10` status triggers on `pull_request_target` with `branches: [main]`, never on plain `pull_request`. Gitea resolves a `pull_request` workflow DEFINITION from the PR''s own head commit, so under that trigger a PR editing `.gitea/workflows/review-verdict.yml` ran its own rewritten copy and could post `h10=success` for itself; `pull_request_target` resolves the definition from the base instead. The `branches: [main]` filter is part of the rule, not a refinement of it: base resolution only relocates the rewrite from the head to the base, so without the filter a PR opened into an attacker-pushed base branch runs that branch''s gate. `pull_request_target` is safe HERE only because this job never checks out or executes head-supplied code — it checks out `base.sha` and runs only that tree''s scripts (`ci.shared-pr-file-enumeration`); reintroducing a head checkout under this trigger would be worse than the bug it fixed. This closes the rewrite route through THIS workflow and does NOT close the class: Gitea injects a write-capable `GITEA_TOKEN` into EVERY job, so any ref-resolved workflow — and a collaborator''s own API token, since branch protection binds the context and not its issuer — can still forge `review-verdict/h10`. The credential half is now RESOLVED in `ci.actions-credential-scoping` (#697): CI''s registry secret was the ADMIN account''s basic auth and is now a PAT that cannot post a status, which removes the ADMIN escalation and that credential''s route (a user credential''s forgery carries a real `creator` and is inherited as a human verdict; an Actions job''s carries `creator: null` and is re-derived — but do NOT read that asymmetry as protection: re-derivation fires only on the trigger''s `types`, and posting a status is not one of them, so a POST timed after the last PR event simply stands). It does not remove EVERY route: `RENOVATE_TOKEN` is a `write:repository` bot PAT in the same secret store, reachable by any PR-added workflow. The injected token stays write-capable until Gitea >=1.26 with a Restricted default (server-management#714), and a collaborator''s own token remains unfixable; the exemption path has its own separate defects in #698.'
|
||||
signals: 'workflow definition resolved from head, PR rewrites the gate that judges it, self-approve a required status check, pull_request_target vs pull_request, gate trigger branches filter, attacker-supplied base branch, how to test a change to review-verdict.yml, workflow not exercised by its own PR, gate edit goes live only on merge, required_approvals 0 does not bind an author, forged commit status inherited by sha · paths: `.gitea/workflows/review-verdict.yml`, `scripts/tests/test_pr_changed_files.py` · issues: #672, #663, #649, #622'
|
||||
mechanics: '`on: pull_request_target: {branches: [main], types: [opened, reopened, synchronize, ready_for_review, edited]}` (`edited` added by `ci.exemption-provenance` so a retarget reclassifies); asserted by `test_the_workflow_trigger_is_pull_request_TARGET_scoped_to_main` in `scripts/tests/test_pr_changed_files.py`; the job''s own context is renamed to `... (pull_request_target)` and must stay OUT of branch protection''s required list'
|
||||
---
|
||||
@@ -54,7 +54,11 @@ inventory is not a short list: Gitea injects `GITEA_TOKEN` into **every** job, d
|
||||
read/**write**, so head-resolved, `push`-triggered and `workflow_dispatch` workflows alike are routes
|
||||
(1.24+ loads a dispatched definition from the selected branch). A collaborator's own API token is a
|
||||
route with no workflow at all — branch protection binds the *context*, not its issuer. Full inventory
|
||||
in `#697`; the exemption path's own defects are `#698`. No in-repository test can establish
|
||||
in `#697`, whose credential half is resolved in `ci.actions-credential-scoping` — the registry secret
|
||||
no longer carries status-write. That does NOT leave the workflow routes provenance-free: any
|
||||
PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store,
|
||||
whose status carries a real creator and IS inherited (`#742`). The exemption path's
|
||||
own defects are `#698`. No in-repository test can establish
|
||||
status-authority isolation: the sibling guard added here catches only plain-text naming of the
|
||||
context.
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
key: ci.required-job-step-execution-markers
|
||||
title: '2026-08-10 — every consequential `run:` step in docker-build.yml''s two REQUIRED jobs records that it executed, and a trailing guard fails the job when the set is incomplete (#756)'
|
||||
status: active
|
||||
since: '2026-08-10'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'A step the runner declines to interpolate is DROPPED and the job still concludes `success` (`ci.workflow-run-body-no-expressions`). In `review-verdict.yml` that is fail-CLOSED — the required status is absent and the merge is blocked. In `docker-build.yml`''s `test` and `migrations` it is fail-OPEN: those are the other two required contexts on `main`, so the check reports green having done no work. So in those two jobs every `run:` step that is not `continue-on-error: true` calls `"$GITHUB_WORKSPACE/scripts/ci-step-ran.sh" mark <key>` as its FIRST act, and the job''s LAST step calls `ci-step-ran.sh assert --always <keys> --gated <keys>`, which fails the job when an expected key was never recorded. PER STEP, not per job: a marker written by the first step only proves the job started, while the drop that costs something is `Test` or the migration replay. The guard carries NO `if:` — the default `success()` is the wanted condition, because a genuine failure in an early step legitimately skips every later one and an `always()` guard would announce a false "these steps never executed" on every ordinary red build; the invariant that makes the omission safe is that the guard is skipped only when an earlier step FAILED, which already fails the job, so guard-skipped implies job-red and every path to a green job runs the guard. Separately and independently, no `${{` OPENER may appear in any `run:` body of those two jobs OR of `build` — the drop mechanism requires the opener, so banning it makes the class unreachable rather than merely caught, and an UNCLOSED opener triggers the same rewrite as a well-formed pair. Pass values in through the step''s `env:`, which is interpolated per value. The two halves have DIFFERENT scopes on purpose: markers cover the required pair, while the ban also covers `build`, whose `Smoke + IPTV E2E` step runs AFTER the image is pushed, so a drop there publishes a release candidate that was never booted and that `DeployStack jazz-media` then promotes. `functional-e2e` is delimiter-free but deliberately excluded (advisory by declaration), and `api-docs`/`format` keep one `github.base_ref` each and gate nothing that ships. The ban is enforced on the RELEASE PATH itself, not only in review (#767): a `scan` job runs the PyYAML-based ban test and `build` lists it in `needs:`, so a delimiter means `build` never runs and no image is published. A guard STEP inside `build` was tried first and is wrong — a step cannot protect the job it publishes from, and "my body has no opener so I cannot be dropped" is circular when only the PR-only test enforces that. The pytest in `script-tests` remains, but it is `on: pull_request` and not a required context, so it alone left the tag path unchecked.'
|
||||
signals: 'required check green but no work done, step never ran but job green, Build & test green in seconds, EF migration integrity green without replaying, missing Run Main step marker, Unable to interpolate expression format(, dropped step docker-build, ci-step-ran.sh, marker file, expression delimiter in a required job · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-step-ran.sh`, `scripts/tests/test_ci_dropped_step_guard.py`, `scripts/tests/test_ci_release_path_scan_job.py` · issues: #756, #751, #684, #767'
|
||||
mechanics: '`scripts/ci-step-ran.sh` owns the marker path so it exists ONCE and the write and the read cannot diverge. It is keyed on `GITHUB_JOB`/`GITHUB_RUN_ID` — REQUIRED, refusing rather than falling back to a reusable name — plus `GITHUB_RUN_ATTEMPT`. All three REFUSE rather than falling back to a reusable name. The third was warn-and-default until its presence was measured: grepping a log for the variable NAME proves nothing, and inferring it from the absence of a stderr warning proves nothing either (stderr capture was itself unestablished), so `assert` was made to print `Marker identity: job=… run=… attempt=… (from the runner)` on STDOUT and the answer was read off run 1916 for both required jobs. That line is retained as standing evidence. Do NOT justify the keying with #751''s "RUNNER_TEMP is /tmp, not a private per-job dir": that was measured on a job with no `container:` and does not transfer — these jobs get a fresh container, which is the primary protection, and the keying is defence in depth. Held by `scripts/tests/test_ci_dropped_step_guard.py`: static (marker set derived from the workflow equals the guard''s expectations, bucket matches each step''s `if:`, guard is last / has no `if:` / is not advisory / has no delimiter) and behavioural (the guard''s real command line executed against markers written by the steps'' real marker lines, dropping each key in turn). The release-path `scan` job (#767) runs the existing PyYAML-based ban test rather than a second implementation, so there is no drift surface; `scripts/tests/test_ci_release_path_scan_job.py` holds the WIRING instead — that `build` needs it, that it carries no job-level `if:` (one excluding the tag push restores the hole, one skipping the job skips `build` too), that no step is advisory, that it actually invokes the ban test, and that its own run bodies are delimiter-free. Its steps carry markers and a trailing assert of their own, verified by the same drop-each-key-in-turn behavioural pattern.'
|
||||
---
|
||||
|
||||
**Why per step, when #756 proposed per job.** A job-start marker answers "did this job begin", which
|
||||
was never in doubt. The fail-open it is supposed to close is a required context reporting success
|
||||
while the work inside it did not happen, and the steps that carry that work are `Test`, `Build` and
|
||||
the two migration replays — all of them well past step one. A guard positioned where it cannot see
|
||||
the case it was built for is the "guard that never executed" failure one level up, and this repo has
|
||||
now shipped that twice in the same file (#751's retarget fence, and #751's own guard).
|
||||
|
||||
**Why a script rather than an inline body, when #751 chose inline.** Two reasons and the second is
|
||||
the load-bearing one. The path literal exists once, so the write and the read cannot drift — #751
|
||||
carries it twice and spends real test effort proving the copies agree, because a divergence reddens
|
||||
every run and then gets deleted as broken. And a one-line `run: scripts/ci-step-ran.sh …` cannot
|
||||
contain an expression delimiter, so the mechanism being guarded against cannot drop the guard. #751's
|
||||
own record names that as the stronger construction and settled for inline only because its
|
||||
measurement showed it was not required there.
|
||||
|
||||
**Why a script is acceptable here and would NOT be in `review-verdict.yml`.** That workflow checks
|
||||
out the PR's BASE precisely so a PR cannot supply the code that judges it. `docker-build.yml` is
|
||||
head-resolved by design — a PR already supplies every test this job runs — so calling a script from
|
||||
the head adds no authority a PR did not already have. This is a correctness gate against silent
|
||||
no-ops, not a security gate against a hostile PR; that job belongs to `review-verdict/h10`. Do not
|
||||
carry this reasoning back into the gate workflow.
|
||||
|
||||
**The premise was re-measured on the BUILD lane, not inherited.** The whole guard rests on the runner
|
||||
still executing a LATER step after dropping an earlier one. #751 established that on the `small`
|
||||
lane; these two jobs run in a `container:` on `ubuntu-latest`, which is a different lane, so assuming
|
||||
it transfers would be the same shape of mistake the guard exists to catch. Measured by scratch PR
|
||||
#765 (Gitea 1.27.1, 2026-08-10), which reintroduced the exact #751 defect — an invalid expression
|
||||
payload inside a shell comment — in the `test` job's `revalidate` step. The step was dropped, the
|
||||
other eleven markers were still recorded — ten of them AFTER the drop, `detect` being the earlier
|
||||
eleventh — and the guard was the ONLY failing step
|
||||
in the job — so without it that run would have concluded `success` having skipped a step. The SAME
|
||||
run supplies the positive control on the same lane: its untouched `migrations` job marked all six
|
||||
steps, reported `All 6 expected step(s) executed`, and concluded `success`.
|
||||
|
||||
A second probe (PR #766, run 1913) settled the one path on which the `if:`-less guard could have been
|
||||
a silent no-op: a FAILING `continue-on-error` step. Had that flipped `success()`, the guard would be
|
||||
skipped on a still-green job. It does not — the advisory step failed, the guard ran anyway, reported
|
||||
`All 12 expected step(s) executed`, and the job stayed `success`. Full log extracts in
|
||||
docs/ci-cd.md.
|
||||
|
||||
**The two halves are deliberately different in kind, and neither is redundant.** The delimiter ban is
|
||||
static and absolute, and it makes the defect class UNREACHABLE in these jobs rather than merely
|
||||
detected — it is the cheaper and more general half, and it is enforceable today only because both
|
||||
jobs were already delimiter-free (measured 2026-08-10: `test` 0, `migrations` 0), and `build` was
|
||||
brought in by moving its two payloads to `env:` — leaving `api-docs` and `format` with one
|
||||
`github.base_ref` each, in detect steps that gate nothing that ships. The runtime markers catch a step
|
||||
that fails to run for any OTHER reason, including reasons not yet met. Keeping only the static half
|
||||
would be trusting that this is the only way a step can vanish, which is exactly the assumption #751
|
||||
falsified about shell comments.
|
||||
|
||||
**What this does not claim.** The guard proves a step STARTED, never that it did its work correctly
|
||||
— that is what the step's own exit status is for. It does not cover `uses:` steps, which are not
|
||||
`run:` bodies and cannot be dropped this way.
|
||||
|
||||
An earlier draft dismissed the non-required jobs as "a smaller cost (no required context lies)", and
|
||||
cold review showed that was false for the one that matters. `build`'s only delimiter-bearing body was
|
||||
`Smoke + IPTV E2E`, which runs AFTER `Build and push`: on a `v*` tag the candidate image is already
|
||||
published, and that step is the only thing that boots it. A drop there ships an unsmoked release
|
||||
candidate under a green tick, and prod promotion pulls exactly that image. It was also the cheap case
|
||||
— both payloads were plain values, so moving them into `env:` cost nothing and let `build` join the
|
||||
ban. The claim not to repeat is the draft's dichotomy ("give up interpolation or move into
|
||||
`scripts/`"); the `env:` escape hatch this record prescribes was the answer all along. What genuinely
|
||||
remains uncovered is `api-docs` and `format`, whose one `github.base_ref` each sits in a detect step
|
||||
that gates nothing that ships, and `functional-e2e`, which is advisory by declaration.
|
||||
|
||||
**The `build` ban is now fail-closed on the release path (ersatztv#767 — this was the open
|
||||
residual).** It used to be enforced only by `script-tests`, which is `on: pull_request` and is not a
|
||||
required context, so nothing re-checked it when a release was actually cut: a delimiter that reached
|
||||
`main` would still drop `Smoke` on the tag build and report green. A `scan` job now runs the
|
||||
PyYAML-based ban test and `build` lists it in `needs:`, so a delimiter means `build` never runs and
|
||||
no image is published.
|
||||
|
||||
**Two designs were tried, and the first one's failures are the reusable part.** The first put a
|
||||
bespoke stdlib scanner in `build` itself as an unconditional step before `Build and push`. Two
|
||||
independent reviews rejected it on two counts, both easy to re-invent:
|
||||
|
||||
- **A guard step cannot protect the job it lives in.** `build` publishes, so a guard step there is
|
||||
fail-OPEN if the runner drops it. The defence offered — "the guard's own body has no opener, so it
|
||||
cannot be dropped" — is circular, because the only thing enforcing that property was the same
|
||||
PR-only, non-required test being backstopped. A `needs:` edge is not circular: a red job skips its
|
||||
dependents by construction.
|
||||
- **A hand-written parser was strictly weaker than the check it backstopped.** It hand-parsed YAML to
|
||||
avoid provisioning PyYAML on `build`'s bare runner, and review found ~10 false NEGATIVES in one
|
||||
round (flow mappings, a quoted `"run":` key, aliases, multiline quoted scalars). For a security
|
||||
gate only false negatives matter, so this was worse than useless — it looked like enforcement. Do
|
||||
not re-attempt a bespoke scanner to save provisioning a dependency; run the real test.
|
||||
|
||||
**Why this needs no third marker bucket.** The deferral assumed the answer had to be markers on
|
||||
`build`, requiring a bucket that models `Smoke`'s publish-ref `if:`. It does not: the delimiter class
|
||||
is a *static* property of the workflow text, so a job that reads the text catches it without
|
||||
modelling any `if:`. The marker buckets are unchanged. Per-step markers on `build` remain a genuine
|
||||
smaller residual — they would catch a drop caused by something other than a delimiter.
|
||||
|
||||
**What this does not claim.** That no step can ever fail to run for another reason. The `scan` job's
|
||||
own steps carry markers and a trailing assert, which moves the terminal assumption rather than
|
||||
removing it: to fail open you must now drop the pytest step AND the assert step, not either alone.
|
||||
|
||||
**Measured, not assumed.** See the closing record on ersatztv#767 for the run ids of the poisoned and
|
||||
control dispatches. The arrangement: a `workflow_dispatch` on a scratch branch whose `Smoke` body
|
||||
carries a deliberate delimiter must redden `scan` and leave `build` skipped, and the same dispatch
|
||||
without the poison must pass. Note that "no image was published" is NOT part of the evidence — on a
|
||||
scratch ref `Build and push` has `push: false` regardless, so that conjunct could not have come out
|
||||
the other way; the discriminating observation is `scan` red and `build` skipped. Do NOT repeat the
|
||||
cost estimate an earlier draft gave ("would require pushing a real `v*` tag").
|
||||
@@ -7,7 +7,7 @@ supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'The `review-verdict/h10` job counts `change_target_branch` events on the PR''s issue timeline at run start and again immediately before its POST, and writes NOTHING if the count moved. The COUNT is the key because the branch NAME is ABA-vulnerable — `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a forged exemption — while the event count is monotonic and cannot alias. Abstaining is a handoff, not a stall, and that is the property the design rests on: every retarget fires `edited`, which is in this workflow''s `types:`, so the event that makes a run abstain has already queued a successor whose window opens after it; the induction terminates when retargeting stops and the last run writes the final answer. `updated_at` was REJECTED as the key because it also moves for comments and labels, which fire none of this workflow''s `types:` — a run could abstain with no successor coming, which is a real stall. The count is trusted only when paging reached a validated EMPTY page; an untrusted count (unreadable page, non-array body, non-numeric length, page cap hit) blocks the exemption `success` ONLY and still lets `pending` through, because `pending` cannot turn an unreviewed head green while withholding it would strand ordinary PRs for no safety gain. SEPARATELY, and for the human-verdict race the fence does nothing about: after posting an exemption `success` the job re-reads `/statuses/{sha}` and, if a human `Review-verdict:` row appeared with an id ABOVE a high-water mark taken just before the POST, overwrites its own status with `pending` and logs an error. The repair is `pending`, NEVER a copy of the human''s state, since re-posting their `failure` under the machine credential would attribute a human verdict to the job; its description is a SENTINEL that the classification refuses to grant an exemption over AND re-writes verbatim on every later run, so the block is a FIXED POINT rather than decaying — writing the generic `pending` description there instead erases the marker and the exemption simply returns one event later. The mark is captured BEFORE the last-moment re-read, not merely before the POST — a later mark leaves a multi-round-trip blind gap in which a verdict is neither seen by the re-read nor repaired afterwards. The id comparison is load-bearing: a mere presence test would fire forever on a base-mismatched verdict that `read_existing_verdict` deliberately declines to honour, deadlocking that PR''s exemption permanently. Finally, a run whose last-moment re-read finds a sentinel it did not see at its FIRST read ABSTAINS instead of posting: that can only mean an overlapping run repaired a raced verdict mid-flight, and this run''s `success` — frozen at classification time, with the human row below its own mark, so neither the fence nor the post-write check would catch it — would otherwise bury the rejection. That is the one path in this design that failed toward SUCCESS rather than `pending`. The post-write check counts TWO row shapes above the mark, not one — a human `Review-verdict:` row AND a machine sentinel — because with two overlapping runs the human row can sit BELOW the second run''s mark while the first masks it and only then writes the sentinel, leaving the second to post its own `success` on top; counting the sentinel converges both runs on the fixed point instead.'
|
||||
signals: 'stale review-verdict run overwrites a fresher one, retarget ABA against the docs-only classifier, concurrency group does not serialize pull_request_target, gitea auto-cancel push vs pull_request_target, forged exemption restored after reclassification, human BLOCKED silently turned green, post-write status verification, change_target_branch timeline count, why does my PR post no verdict status after a retarget · paths: `.gitea/workflows/review-verdict.yml`, `scripts/tests/test_pr_changed_files.py` · issues: #706, #698, #672, #663, #622'
|
||||
mechanics: '`count_retargets()` pages `GET /repos/{repo}/issues/{pr}/timeline?limit=50&page=N` (cap 20) setting `rt_count`/`rt_ok`, trusted only on a validated empty page; `retargets_before`/`retargets_before_ok` captured before enumeration, re-counted immediately before the POST; `max_id_before` from `GET /repos/{repo}/statuses/{sha}` (a BARE ARRAY, unlike the combined `/commits/{sha}/status` object); repair POST is `pending`; tests `test_a_RETARGET_DURING_the_run_posts_NOTHING`, `test_a_PR_retargeted_BEFORE_the_run_but_QUIET_during_it_is_STILL_exempt`, `test_an_UNTRUSTED_retarget_count_withholds_the_EXEMPTION`, `test_an_UNTRUSTED_retarget_count_STILL_LETS_PENDING_THROUGH`, `test_a_human_verdict_landing_AFTER_the_POST_is_repaired_to_pending`, `test_a_PRE_EXISTING_human_row_does_NOT_trigger_a_repair`'
|
||||
mechanics: '`count_retargets()` pages `GET /repos/{repo}/issues/{pr}/timeline?limit=50&page=N` (cap 20) setting `rt_count`/`rt_ok`, trusted only on a validated empty page, which is a page of EITHER `null` (what this endpoint really returns past the end) or `[]` — an `array`-only type gate read the real terminator as unreadable and withheld every exemption (#751); `retargets_before`/`retargets_before_ok` captured before enumeration, re-counted immediately before the POST; `max_id_before` from `GET /repos/{repo}/statuses/{sha}` (a BARE ARRAY, unlike the combined `/commits/{sha}/status` object); repair POST is `pending`; tests `test_a_RETARGET_DURING_the_run_posts_NOTHING`, `test_a_PR_retargeted_BEFORE_the_run_but_QUIET_during_it_is_STILL_exempt`, `test_an_UNTRUSTED_retarget_count_withholds_the_EXEMPTION`, `test_an_UNTRUSTED_retarget_count_STILL_LETS_PENDING_THROUGH`, `test_a_human_verdict_landing_AFTER_the_POST_is_repaired_to_pending`, `test_a_PRE_EXISTING_human_row_does_NOT_trigger_a_repair`'
|
||||
---
|
||||
|
||||
`ci.exemption-provenance` closed three routes into the exemption path and left one residual it named:
|
||||
@@ -70,3 +70,31 @@ marker, so the exemption returned two events later instead of one. Only a re-pos
|
||||
4. **A timeline over the 20-page cap can never be exempted** — `rt_ok` stays `no` on every run, so only
|
||||
a human verdict clears it and comment-flooding becomes a fail-closed denial of exemption.
|
||||
Negligible at 1000 events; the log says so rather than promising a later run will fix it.
|
||||
|
||||
**CORRECTION, 2026-08-06 (ersatztv#751).** Residual 4 above described as a narrow edge case what was
|
||||
in fact the universal behaviour: `rt_ok` stayed `no` on **every** pull request, not only over-cap ones,
|
||||
so the fence withheld **every** exemption `success` from the day it shipped. A page past the end of
|
||||
this endpoint is the JSON value `null`, not `[]` (measured at Gitea 1.27.1 on PR #752; the same
|
||||
instance returns `[]` for an empty `/issues/{n}/comments`, so it is not consistent between endpoints).
|
||||
`count_retargets` gated on `type == "array"` and therefore read the real terminator as unreadable,
|
||||
never reaching the validated empty page it required. Renovate and docs-only PRs got no status at all.
|
||||
|
||||
Two reasons it read as deliberate rather than broken, both worth carrying forward:
|
||||
|
||||
- **It never ran.** This fence shipped in 8f6d4f443 — the same commit whose prose comment stopped the
|
||||
classify step from executing at all (`ci.workflow-run-body-no-expressions`). Merging a guard and
|
||||
first executing it are different events, and only the second tells you anything.
|
||||
- **The double asserted the wrong shape while claiming to be measured.** The stub's comment read "Real
|
||||
shapes, measured on this instance and deliberately mirrored" and it printed `[]` past the end. So the
|
||||
`array`-only gate was never exercised by the suite either. Correcting the double and restoring the
|
||||
old gate reddens most of the fence suite — 18 tests when first measured, 21 once three more
|
||||
fence-dependent tests existed. The invariant, not the number, is that every one of them had been
|
||||
green for the wrong reason. A fidelity claim in a test double is an assertion, and it decays 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 at jq 1.6 (`ci.jq-version-contract`), and both `null`
|
||||
and `[]` terminate the walk. `test_the_fence_TRUSTS_the_count_and_POSTS_when_the_timeline_terminates`
|
||||
is parameterised over both shapes and asserts the POSTED STATUS rather than the log line — on the real
|
||||
probe run the log said `Decision: state=success` and the job still posted nothing, so the decision and
|
||||
the write are separate events and only the write is what a merge reads.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
key: ci.workflow-run-body-no-expressions
|
||||
title: '2026-08-06 — an expression delimiter anywhere in a `run:` body, INCLUDING in a comment, silently drops the step and reports the job green (#751)'
|
||||
status: active
|
||||
since: '2026-08-06'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'A `run:` body is not shell when the runner reads it: the runner scans the whole scalar for the expression opener and, on finding one, rewrites the ENTIRE body into a single `format(...)` call. That rewrite is all-or-nothing, so a payload that does not evaluate fails the interpolation of the whole scalar — and the runner then DROPS THE STEP AND CONCLUDES THE JOB `success`. A shell comment is therefore NOT inert. In `.gitea/workflows/review-verdict.yml` no expression delimiter may appear in ANY `run:` body, in code or in prose, because a dropped step there is a dead merge gate rather than a failed build; pass values in through the step''s `env:` block, which is interpolated per value so a bad payload cannot take the body with it, and describe an expression in prose by NAMING it (`a github.event.pull_request.number expression`) rather than quoting the delimiters. Repo-wide the rule is weaker and its reach must be stated precisely rather than generously: every expression payload in every workflow field must have a HEAD TOKEN naming a context or function the runner can resolve. That catches the defect above and a nonexistent context; it does NOT catch a syntactically invalid payload whose tokens are all known (`${{ github.ref == }}`), a renamed output (every token after the first is skipped), or an unclosed opener — those need an expression parser, and the guard is kept permissive on purpose because a red here blocks every merge through the combined status. In `review-verdict.yml` specifically, any step whose non-execution is consequential is paired with a start-marker guard that FAILS the job when the marker is absent, and that guard''s own body must be expression-free — a guard the guarded mechanism can silently delete is worse than none. That pairing now also covers `docker-build.yml`''s `test` and `migrations` jobs, where a dropped step is fail-OPEN (the required check goes green having done no work) rather than fail-closed as it is here — see `ci.required-job-step-execution-markers`, which adds per-STEP markers there and extends this file''s delimiter ban to those two jobs. It is still not a repo-wide property, but the remaining exceptions are narrower than this record originally said: `build` was brought into the ban too (its `Smoke + IPTV E2E` runs AFTER the image is pushed, so a drop there ships an unsmoked release candidate — its two payloads moved to `env:`, so the ban was free), leaving only `api-docs` and `format`, whose one `github.base_ref` each sits in a detect step that gates nothing that ships.'
|
||||
signals: 'Unable to interpolate expression format(, step never ran but job green, missing Run Main step marker, review-verdict/h10 absent after a green run, docs-only PR unmergeable, Renovate PR unmergeable, exemption stopped working, expression in a shell comment, workflow comment changed behaviour · paths: `.gitea/workflows/review-verdict.yml`, `scripts/tests/test_pr_changed_files.py` · issues: #751, #706, #748'
|
||||
mechanics: '`RAN_MARKER` written at the top of the classify step and asserted by the `Assert the classifier actually executed` step (`if: always()`, expression-free body, `exit 1` on a missing marker); static guards `test_the_verdict_workflow_has_NO_expression_delimiter_in_any_run_body` (raw scalar, absolute, gate file only) and `test_every_workflow_expression_names_a_REAL_context_or_function` (repo-wide, allow-list of contexts/functions) plus `test_a_dropped_classify_step_FAILS_the_job_instead_of_going_green` (pins marker path agreement and guard ordering across ONE yaml parse)'
|
||||
---
|
||||
|
||||
**How it happened, which is the part that generalises.** The #706 note explaining why a concurrency
|
||||
group does not work in `review-verdict.yml` quoted a `concurrency:` snippet containing a PR-number
|
||||
expression *as an illustration*, inside a shell comment. `pr number` is not a valid expression. From
|
||||
8f6d4f443 (2026-08-03) to 2026-08-06 the classify step therefore never ran, `review-verdict/h10` was
|
||||
posted by nothing but a human hand, and both exemption classes silently stopped working — while every
|
||||
run reported success. The prose documenting a fix disabled the fix.
|
||||
|
||||
**Why nothing caught it.** Every pre-existing workflow-shape test in
|
||||
`scripts/tests/test_pr_changed_files.py` reads `_code_lines()`, which strips comment lines. That is
|
||||
correct for what it was for — its own docstring notes that prose legitimately discusses
|
||||
`pulls/N/files`, and a raw scan would redden the repo over a piece of writing — but it encodes the
|
||||
assumption this bug falsifies: that a comment in a workflow cannot change behaviour. Inside a `run:`
|
||||
scalar it can. The strict test added here reads the RAW scalar for exactly that reason and must never
|
||||
adopt `_code_lines`.
|
||||
|
||||
**The silent green is the defect; the delimiter was only the trigger.** An absent required status
|
||||
reads as "not reviewed yet" on an ordinary PR, which is indistinguishable from the correct pending
|
||||
state — so a normal PR looked normal while the gate was dead. The visible cost landed on the two
|
||||
classes with no human in the loop: PR #739 (docs-only) merged 2026-08-05 with ZERO commit statuses on
|
||||
its head, and got in only because admin force-merge was still enabled. #743 removed that escape the
|
||||
next day, so by the time this was found the workaround that had been absorbing the bug was gone and
|
||||
the next docs-only or Renovate-manifest PR would have been permanently stuck. The two Renovate PRs in
|
||||
the window escaped by timing alone, merging minutes before the bad commit landed.
|
||||
|
||||
**Scope of the strict rule, and why it is not repo-wide.** As of 2026-08-06, `docker-build.yml`,
|
||||
`ci-image.yml` and `pr-checks.yml` interpolated into `run:` bodies legitimately (7 occurrences then;
|
||||
#756 removed `build`'s two, leaving 5 today — see below). A repo-wide ban would be
|
||||
false and would be deleted the first time it got in someone's way. `review-verdict.yml` earns the
|
||||
absolute rule on two counts: it writes the branch-protection-required status, and its `run:` bodies
|
||||
are ~700 lines of dense prose — the only place the delimiter has ever appeared by accident.
|
||||
|
||||
The first of those two counts turned out to apply elsewhere as well, and #756 acted on it: the
|
||||
absolute ban now also covers `docker-build.yml`'s `test` and `migrations` jobs, which write the other
|
||||
two required contexts and were delimiter-free already, so the rule cost nothing to impose there. The
|
||||
remaining 2 occurrences inside `docker-build.yml` — `api-docs` and `format`, one `github.base_ref`
|
||||
each — sit in jobs that gate nothing that ships (5 repo-wide, counting `ci-image.yml` and the two
|
||||
`pr-checks.yml` gates). `build` is banned too, and NOT because it is required (it is not): its
|
||||
`Smoke + IPTV E2E` step runs after the image is pushed, so a drop there publishes a release candidate
|
||||
that was never booted. Read this paragraph as scoping the rule to steps whose non-execution is
|
||||
CONSEQUENTIAL — required contexts and the release path — rather than to this one file.
|
||||
|
||||
**The probe found a SECOND, independent reason the gate posted nothing**, and it is why fixing the
|
||||
interpolation alone would not have restored the exemptions: a page past the end of
|
||||
`/issues/{n}/timeline` is JSON `null`, not `[]`, so the retarget fence never trusted its count for ANY
|
||||
PR and withheld every exemption `success`. Corrected in `ci.verdict-write-retarget-fence`, whose stated
|
||||
residual had described that universal behaviour as a narrow over-cap edge case. Both defects shipped in
|
||||
the same commit, which is the general lesson: a guard that has never executed has told you nothing, and
|
||||
merging it is not executing it.
|
||||
|
||||
**A THIRD instance of the same server behaviour was found by cold review of this fix**, and it is
|
||||
the reason to distrust "I fixed the two I could see". `GET /commits/{sha}/status` also returns
|
||||
`statuses: null` — not `[]` — for a head with no statuses yet (measured on PR #739's head 5fa672e2:
|
||||
`{"state":"pending","total_count":0,"statuses":null}`). `read_existing_verdict` gated on
|
||||
`.statuses | type == "array"` and took its `exit 1` path, posting nothing: fail-closed, but the same
|
||||
user-visible outcome again. Its double printed `{"statuses": []}` at all three no-verdict sites, so
|
||||
that branch was unreachable in the suite; correcting the double and restoring the old gate turns 40+
|
||||
tests red. `scripts/pr-changed-files.sh` was swept too and is unaffected — `pulls/{n}/files` returns
|
||||
`[]`. The generalisable rule is that a nil Go slice serialises to `null`, so EVERY list-shaped field
|
||||
on this API is suspect, and a per-endpoint measurement is the only way to know.
|
||||
|
||||
**Restoring the exemptions restores a hole that had been dead**, and this is worth saying rather than
|
||||
presenting the change as pure repair. `DOCS_ONLY` matched `CLAUDE.md` and `AGENTS.md`, the documents
|
||||
that define the completion protocol and the H10 rule itself — so those were auto-exemptible while
|
||||
`.claude/` was protected, which is the same self-exemption the workflow header rules out, one
|
||||
directory over. Reachable only because exemptions work again, hence fixed here (both added to
|
||||
`PROTECTED`; see `ci.exemption-provenance`). For the same reason, #706's known residual — the
|
||||
sub-round-trip ABA window, "narrowed and observable, not closed" — comes back with the working fence:
|
||||
while `rt_ok` was never `yes`, route 1 was closed by accident.
|
||||
|
||||
**Three guards were proposed or written for the same hole and the first two were no-ops** — the hole
|
||||
being that concluding "no verdict exists" is what licenses posting over one. `total_count` is per-PAGE
|
||||
here (`?limit=1` on a 6-context head gives `len=1, total_count=1`), so length-vs-total is equal by
|
||||
construction; and "refuse on a full page at `limit=100`" was DEAD CODE, because the instance caps
|
||||
`limit` at `MAX_RESPONSE_ITEMS`, measured at 50 — a cap this repo already documented in three places
|
||||
before the guard was written against 100. The working version asks the server: read page 2 when the row
|
||||
is absent from page 1, and refuse if it carries anything. Cap-independent, so no reconfiguration
|
||||
re-breaks it. Second, `jq -r` renders the number `0` and the string `"0"` identically, so the zero
|
||||
check requires the JSON type as well. Neither was a live failure — both are the difference between a
|
||||
guard that holds because the input happens to be well-formed and one that holds because it checks.
|
||||
|
||||
**The tests written to close a review finding then needed closing themselves**, which is the honest
|
||||
shape of work on this file. The behavioural guard test first extracted the two marker lines by text and
|
||||
ran them alone — which passes even if the write is moved into a function nobody calls. It now executes
|
||||
the classify body's real PREFIX down to the write, reproducing the production control flow instead of a
|
||||
reconstruction of it. The anti-vacuity check first hand-counted `run:` keys with a regex, which
|
||||
false-redded legal spellings (`- run: |`, a single-line `run: echo ok`) and could count a `run: |`
|
||||
inside a heredoc; hand-parsing YAML to validate a YAML parse is the wrong shape, so it now asserts on
|
||||
content — the walk reached at least three bodies and one over 5000 characters.
|
||||
|
||||
**Verified by mutation, not by a green suite.** All six mutations produce a red and the restored tree
|
||||
is green: reintroducing the exact defect (caught by both the strict and the general test), deleting
|
||||
the guard step, deleting only the marker write, weakening `if: always()`, turning the guard's
|
||||
`exit 1` into `exit 0`, and putting a delimiter in the guard's own body.
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
key: mcp.tool-schema-openapi-parity
|
||||
title: '2026-08-06 — every MCP tool declares exactly its endpoint''s OpenAPI request-body fields and query parameters, asserted in CI (#754, #757)'
|
||||
status: active
|
||||
since: '2026-08-06'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'Every POST/PUT/PATCH tool in `ToolCatalog` declares exactly the request-body properties its endpoint accepts, each with a matching type, and EVERY tool (read and write) declares exactly its endpoint''s query parameters, both asserted against the generated `ErsatzTV/wwwroot/openapi/v1.json` (linked into `ErsatzTV.Mcp.Tests`) by `Every_Write_Tool_Should_Declare_Exactly_Its_OpenApi_Request_Body_Fields` and `Every_Tool_Should_Declare_Exactly_Its_OpenApi_Query_Parameters`. A field the endpoint accepts but the tool omits is a DEFECT, not a deferral: on the full-replace tools (channel update, schedule update, custom-order) the omission is silently applied as a clear. The write tools are NOT uniformly full-replace — add-collection-items is additive, and several leave an omitted field unchanged — so each tool description states its own semantics. An omitted query parameter is UNREACHABLE, not merely undocumented, because `ToolArgumentValidator` rejects undeclared arguments.'
|
||||
signals: 'MCP tool schema drift, full-replace write, silently dropped field, graphicsElementIds, padToNearestMinute, additionalProperties false · paths: `ErsatzTV.Mcp/ToolCatalog.cs`, `ErsatzTV.Mcp.Tests/ToolCatalogTests.cs`, `docs/mcp.md` · issues: #754, #757, #58, #616'
|
||||
mechanics: '`ErsatzTV.Mcp.Tests/ToolCatalogTests.cs`; `ErsatzTV.Mcp.Tests.csproj` links `openapi/v1.json`'
|
||||
---
|
||||
|
||||
`ToolCatalog.ChannelFields()` declared 27 of `UpdateChannelRequest`'s 28 properties. The missing one
|
||||
was `graphicsElementIds`, which attaches channel-level graphics elements including the built-in On
|
||||
Now/Next overlay (`graphics.channel-level-attachment`).
|
||||
|
||||
The cost was not "one field you cannot set". `PUT /api/v1/channels/{id}` is a **full replace**, and
|
||||
the tool's own description instructs the caller to *"send the full desired state"* — which the schema
|
||||
could not express. An agent that faithfully GET-edit-PUT a channel detached every attached graphics
|
||||
element, with a `200` and no error. Nothing surfaced until the overlay stopped rendering at the next
|
||||
transition, hours later. That is the `optional-parameter-on-shared-primitive-is-opt-out` shape: the
|
||||
omission is invisible at the call site and only observable as missing pixels.
|
||||
|
||||
Fixing the one field would have left the mechanism intact, and the mechanism had already produced a
|
||||
second instance: `ScheduleFlags()` omitted `padToNearestMinute`, which both `CreateScheduleRequest`
|
||||
and `UpdateScheduleRequest` carry and `UpdateProgramScheduleHandler` writes unconditionally — so
|
||||
`ersatztv_update_schedule` silently cleared a configured pad the same way. Nothing tied a tool's
|
||||
declared arguments to the contract it wraps, so the next added DTO property would have drifted too.
|
||||
|
||||
So the guard is the decision, and it is asserted against the **generated OpenAPI document** rather
|
||||
than the DTO types: `v1.json` is the actual wire contract, it is already regenerated by
|
||||
`scripts/update-openapi.sh` as part of the API checklist, and asserting against it keeps
|
||||
`ErsatzTV.Mcp.Tests` free of a project reference to the whole ASP.NET host. The test derives each
|
||||
tool's body set exactly as `ErsatzTvApiClient` does — declared arguments minus path parameters, minus
|
||||
query parameters, minus the reserved `ifMatch` header — so the guard cannot disagree with the routing
|
||||
it guards.
|
||||
|
||||
Three anti-vacuity properties are deliberate, per the repo's standing "a test that filters on the
|
||||
property it asserts cannot see what is missing" rule:
|
||||
|
||||
- The **covered write-tool set is pinned by name**, not merely filtered. A tool that stops being a
|
||||
write verb, or a new one that is added, changes this list rather than silently leaving the loop.
|
||||
- A **missing or unrecognised spec is a failure**, never an empty comparison: an absent `v1.json`
|
||||
fails with the path it looked in, and a request body that is not a plain `$ref` (an `allOf`,
|
||||
`oneOf`, or inline schema), or a property whose type is a union this guard has not been taught,
|
||||
fails asking to be taught the shape instead of comparing against `{}`.
|
||||
- **Names are compared with types**, not alone. A name-only guard is the same defect one level down:
|
||||
the tool would advertise `string` for an `int?`, the agent would send `"30"`, and the API would
|
||||
reject it — green test, broken tool. The generator's `["null", T]` nullable form and its `$ref`
|
||||
(enum → `string`, model → `object`) are normalized onto the catalog's vocabulary, arrays down to
|
||||
their element type.
|
||||
|
||||
All were verified by mutation rather than assumed: dropping `graphicsElementIds`, dropping
|
||||
`padToNearestMinute`, retyping either field, drifting an array's element type, and removing the
|
||||
copied spec each turn the suite red, and each failure names the field or path at fault.
|
||||
|
||||
**Query parameters are guarded the same way, across every tool (#757).** A second test compares each
|
||||
tool's routed `QueryParameters` against the spec's `parameters[in=query]` for its path and verb, reads
|
||||
included — the drift that existed when this was written was entirely on reads. An omitted parameter
|
||||
there is worse than an undeclared body field: `additionalProperties:false` means the caller cannot
|
||||
pass it *at all*, so the capability is unreachable rather than merely undocumented (`ersatztv_list_playouts`
|
||||
had lost its channel-name `query` filter and `ersatztv_get_playout_items` its `showFiller`; #616 was
|
||||
the same shape with paging). That test **accumulates** its mismatches and asserts once, so a run
|
||||
reports the whole drift set — failing on the first would invite fixing one tool at a time, which is
|
||||
how the twin in this very issue stayed hidden.
|
||||
|
||||
It also **composes with** the older `Every_Query_Parameter_Should_Be_A_Declared_Property`, and the pair
|
||||
is the clearest illustration in this repo of why "a test that filters on the property it asserts cannot
|
||||
see what is missing" is a rule. That older test filters `Where(t => t.QueryParameters is { Count: > 0 })`
|
||||
— so a tool that lost its query parameters entirely escaped it, which is exactly how `list_playouts` and
|
||||
`get_playout_items` hid. The new test has no filter and reports them as *unreachable*; the old one then
|
||||
checks that a routed parameter is also a declared argument. Neither subsumes the other, and the inner
|
||||
duplicate of the old check was deliberately removed from the new test rather than kept as a second copy.
|
||||
|
||||
**Scope, stated so it is not mistaken for more.** Request bodies are compared for POST/PUT/PATCH only.
|
||||
DELETE is uncovered because `ErsatzTvApiClient` builds a body for POST/PUT/PATCH only, so a body
|
||||
argument on a DELETE tool would be silently dropped; no tool has one today. Header arguments (`ifMatch`)
|
||||
and per-parameter *descriptions* are not compared either — `api.paging-zero-based` is pinned by its own
|
||||
test.
|
||||
|
||||
The type comparison is **lossy by design, at the catalog's ceiling**: the catalog's vocabulary is
|
||||
`{string, integer, number, boolean, object, array<T>}`, so every object component collapses to `object`
|
||||
and every enum to `string`. Swapping one model or enum for another is therefore invisible here
|
||||
(verified by repointing `logo` at a structurally unrelated model — the suite stays green), as is
|
||||
`format` (`int32` vs `int64`). That is the right ceiling rather than a gap to close: comparing deeper
|
||||
than the catalog can express would assert a distinction no tool schema carries, and an opaque object
|
||||
like `logo` is copied through from a GET verbatim, so nested drift cannot cause the silent-clear this
|
||||
record exists to prevent. `integer` vs `number` IS distinguished. The `>1` non-null type-union
|
||||
assertion is a fail-loud guard for a shape this generator does not currently emit, so it is deliberate
|
||||
but **unexercised**.
|
||||
|
||||
The guard is also a **two-job conjunction**, not self-contained: it compares against a checked-in
|
||||
`v1.json`, so it is only as fresh as the regeneration. What keeps it honest is the `api-docs` CI job,
|
||||
whose `^ErsatzTV/Controllers/Api/` path filter covers the directory every request DTO lives in — a
|
||||
new DTO property cannot leave `v1.json` stale without that job going red. That holds for a DTO's OWN
|
||||
properties and no further: a NESTED model such as `ArtworkContentTypeModel` lives in
|
||||
`ErsatzTV.Application/Artworks/`, outside that filter, so changing it can leave `v1.json` stale without
|
||||
the job firing. Pre-existing, and harmless to this guard only because nested shape is not compared.
|
||||
|
||||
`graphicsElementIds` is declared on the **update tool only**, not in the shared `ChannelFields()`:
|
||||
`CreateChannelRequest` has no such property, and the tool schemas are `additionalProperties:false`,
|
||||
so sharing it would make every create call send an unknown property. `padToNearestMinute` is on both
|
||||
schedule requests, so it does belong in the shared `ScheduleFlags()`. The parity test is what makes
|
||||
that per-field placement checkable rather than a matter of care.
|
||||
@@ -5,8 +5,8 @@ status: active
|
||||
since: '2026-07-12'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: A blocking `format` CI job runs `dotnet format --verify-no-changes` scoped only to the PR's changed `.cs` files (never the legacy BOM backlog), and a PR branch must be kept current by rebasing on `origin/main` (never merging main in), enforced by `.husky/pre-push` → `prepush-rebase-check.sh`.
|
||||
signals: 'format-as-you-touch, rebase not merge, BOM backlog · paths: `.husky/pre-push`, `.claude/hooks/prepush-rebase-check.sh` · issues: #311 (H11), #309, #310, #269, #312'
|
||||
rule: 'A blocking `format` CI job runs `dotnet format --verify-no-changes` scoped only to the PR''s changed `.cs` files (never the legacy BOM backlog), and a PR branch must be kept current by rebasing on `origin/main` (never merging main in), enforced by `.husky/pre-push` → `prepush-rebase-check.sh`. H11 has ONE always-on carve-out, #719 — a push in which EVERY ref is under `refs/tags/` skips the freshness check, because a tag push cannot revert merged work, which is the failure mode H11 exists to prevent, and the release cut tags from a branch that is behind `origin/main` (observed on the v26.13.0 cut, #719). A push mixing branch and tag refs is still blocked, and so is a push with zero parsed ref lines (the exemption requires at least one, so empty stdin cannot vacuously disable H11).'
|
||||
signals: 'format-as-you-touch, rebase not merge, BOM backlog, tag-only push exemption, H11 blocks release cut, refs/tags pre-push, vacuous-truth guard · paths: `.husky/pre-push`, `.claude/hooks/prepush-rebase-check.sh`, `scripts/tests/test_prepush_rebase_check_tag_exemption.py` · issues: #311 (H11), #719, #309, #310, #269, #312'
|
||||
mechanics: '`docs/contributing.md` §7; `.claude/hooks/prepush-rebase-check.sh`; `npm run check:api`'
|
||||
---
|
||||
|
||||
@@ -36,5 +36,22 @@ git hook has no "ask"); deliberate escape `ETV_SKIP_REBASE_CHECK=1`. This supers
|
||||
guidance to "merge main into your PR branch." (After a rebase that conflicts in *generated* artifacts —
|
||||
v1.json/v1.d.ts/endpoint-index — regenerate, don't hand-resolve; `npm run check:api` guards.)
|
||||
|
||||
**2a. The tag-only carve-out (#719).** H11 fired on the release cut: tagging a commit on `main` from
|
||||
a branch that is behind `origin/main` tripped the freshness check, and the rebase advice it printed
|
||||
did not even apply — no branch was being pushed. Observed while cutting `v26.13.0` (#719); note
|
||||
`docs/ci-cd.md` → "Cutting a release" documents the tag step itself, not the release-notes-PR flow
|
||||
that leaves the branch behind, so the frequency is attested by #719 rather than by that doc. The hook now reads git's pre-push ref lines (`<local ref> <local sha>
|
||||
<remote ref> <remote sha>`) and exits 0 when every parsed line's *remote* ref is under `refs/tags/`.
|
||||
Two details are load-bearing and easy to regress:
|
||||
- `.husky/pre-push` consumes stdin into `$_prepush_refs` before any guard runs, so it must **forward**
|
||||
those lines (`printf '%s\n' "$_prepush_refs" | …`). Without that the check receives EOF and the
|
||||
exemption is dead code that silently never fires. The unit tests drive the hook directly and would
|
||||
still pass, so this wiring is not covered by them.
|
||||
- The exemption requires **at least one** parsed ref line. "All refs are tags" is vacuously true for
|
||||
zero lines, which would disable H11 for every push; with no lines the hook falls through to the
|
||||
normal freshness check. `scripts/tests/test_prepush_rebase_check_tag_exemption.py` pins both the
|
||||
negative control (branch push from a behind branch still blocked), the mixed branch+tag case, and
|
||||
the two zero-line cases.
|
||||
|
||||
Rationale, as with the whole hook program: make the process rule a derivation/hook, not prose to
|
||||
remember (#303 methodology review). Tracked: #311; sibling #312 (H12 issue-qualification audit).
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
key: release.main-direct-push-disabled
|
||||
title: '2026-08-05 — `main` refuses direct pushes (`enable_push: false`), because a push whitelist would have been a no-op here (#743)'
|
||||
status: active
|
||||
since: '2026-08-05'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are unaffected (`tag_protections` governs those separately), so the release cut still works.'
|
||||
signals: 'direct push to main, push whitelist, enable_push false, branch protection bypass, review-verdict/h10 bypassable without forging, merge consent derived not asserted, pre-receive hook declined, Not allowed to push to protected branch, protected branch, tag_protections, release tag push, GITEA_TOKEN repo write, RENOVATE_TOKEN, site admin bypass, PR-only flow · paths: `docs/ci-cd.md` · issues: #743, #697, #698, #622, #672, #706, #742, server-management#714'
|
||||
mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returns `[]`; repo is `fork: false`, `mirror: false`. NOT measured, source-attested only (Gitea 1.27 `CanBypassBranchProtection`, `services/pull/check.go`, `routers/private/hook_pre_receive.go`): that `block_admin_merge_override: false` would have let an admin `force_merge` past the required contexts — the field was set to true rather than probed, since probing it means merging an unreviewed PR. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout.'
|
||||
---
|
||||
|
||||
**Why a whitelist was the wrong shape.** #743 proposed "a push whitelist on `main` (or disable direct
|
||||
push entirely)" as if the two were interchangeable. They are not, and which one is right depends on a
|
||||
fact about *this* instance: the only accounts with repository write are `timothy` (a site admin) and
|
||||
`renovate`. Every credential in the threat model — an agent session, a collaborator PAT, the
|
||||
`GITEA_TOKEN` Gitea injects into every Actions job — authenticates as one of those two, and
|
||||
overwhelmingly as `timothy`. A whitelist admitting `timothy` therefore admits precisely the identity
|
||||
the control is supposed to constrain. It would have ticked the issue's box while changing nothing.
|
||||
This was measured, not reasoned: the same push was refused under `enable_push: false` and accepted
|
||||
under a whitelist naming `timothy`.
|
||||
|
||||
**Disabling push alone was NOT enough, and the reason is the same argument twice.** The first draft of
|
||||
this record disabled direct push and concluded that `review-verdict/h10` was therefore load-bearing.
|
||||
An independent review caught that this repeated on the merge path exactly the mistake it had just
|
||||
diagnosed on the push path. The push argument was: a whitelist naming `timothy` fails because
|
||||
`timothy` is the identity every credential already holds. The merge path had the identical shape —
|
||||
`block_admin_merge_override` defaulted to `false`, so `CanBypassBranchProtection` returned true for a
|
||||
repo admin and `POST /pulls/{n}/merge` with `force_merge: true` merged straight past a missing or red
|
||||
`h10`. One API call, cheaper than the push route it replaced. **An admin-shaped control that exempts
|
||||
the only admin exempts everybody.** Both fields are now set; treat them as one control, and never
|
||||
cite `enable_push: false` alone as the reason the gate holds.
|
||||
|
||||
**What this actually closes, and what it does not.** It closes the *write-only* credential routes,
|
||||
which is most of #743's own "who can do it" list: the injected `GITEA_TOKEN` (repo write, not admin),
|
||||
`RENOVATE_TOKEN`, and any non-admin collaborator PAT. Those can no longer reach `main` at all, by any
|
||||
path that skips the gate.
|
||||
|
||||
It does **not** close the admin route. `timothy` is a site admin, so a credential holding that
|
||||
identity can `PATCH` either field off, act, and restore it — the exact sequence used to *prove* the
|
||||
push semantics above. Closing that requires agent sessions to run as a scoped non-admin credential,
|
||||
which is a different change with its own cost (packages live in a user namespace; see the "Admin
|
||||
ownership is a real residual" section of `ci.actions-credential-scoping`). Recorded as an accepted
|
||||
residual rather than fixed here, so it is not mistaken for covered. The severity bound from #697 and
|
||||
#743 is unchanged throughout: push access is required, so this is a compromised contributor or a
|
||||
subverted automated session, never an anonymous attacker.
|
||||
|
||||
**Which write surfaces were enumerated.** `git push` (measured, refused), the contents API and by
|
||||
extension the web editor / upload path (measured on a probe branch, refused HTTP 403 — they share the
|
||||
`CanUserPush` predicate, which has no admin special-case and no `unprotected_file_patterns` carve-out
|
||||
since that field is empty), apply-patch / revert / cherry-pick (source-attested, same predicate),
|
||||
force push (`enable_force_push: false`), default-branch deletion (separately refused), and fork-sync /
|
||||
mirror (not applicable: `fork: false`, `mirror: false`). Merge remains the one intended path.
|
||||
|
||||
**Why the release cut does not deadlock.** #743 flagged that the tag path had to keep working, and
|
||||
#719 documents H11 blocking a tag-only push on every release cut. Branch protection is scoped to
|
||||
`refs/heads/main`; tags are governed by an entirely separate mechanism, and `tag_protections` on this
|
||||
repo is empty, so tag pushes are unrestricted by anything except ordinary write permission. Demonstrated
|
||||
rather than assumed: from one worktree, the branch push to `main` was refused and a tag push succeeded.
|
||||
Do not conflate the two mechanisms — disabling branch push says nothing about tags, and a future
|
||||
tag-protection rule would not inherit from this one.
|
||||
|
||||
**The `docker-build.yml` `persist-credentials` question (#743's fourth box), decided and deferred.**
|
||||
Its six `actions/checkout` steps omit `persist-credentials: false`, so a head-resolved job keeps a
|
||||
write-capable credential in `.git/config`. It *should* be set — but not blind, and not in this PR,
|
||||
because two steps run `git fetch --no-tags --depth=100 origin "$base_ref" || true` and feed the result
|
||||
into the changed-file skip logic. That `|| true` means a credential regression does not fail the job;
|
||||
it silently yields an empty changed-file set, and the skip logic then reads "nothing changed". The repo
|
||||
is public, so anonymous fetch is *expected* to cover it — expected is not measured, and the failure
|
||||
mode is silent, which is the shape that has burned this repo before. The correct order is: drop the
|
||||
`|| true` masking so a fetch failure is loud, then set `persist-credentials: false` and confirm both
|
||||
jobs still compute a non-empty changed set on a PR that genuinely changes files.
|
||||
|
||||
**Why this is not redundant with the Husky pre-push hooks.** `.husky/pre-push` guards (H6 done-when,
|
||||
H11 rebase, H13 clean worktree) are client-side and deliberately fail-open — a git hook cannot prompt.
|
||||
They are not installed in CI, not present in a fresh clone until `husky` runs, and `--no-verify`
|
||||
bypasses them, which the worktree workflow uses routinely. They are good friction against mistakes and
|
||||
were never a control against a credential. This record is the server-side half; the hooks remain useful
|
||||
and unchanged.
|
||||
@@ -182,8 +182,24 @@ described as one:
|
||||
|
||||
**Only this file's instance is closed, not the class.** Any head-resolved workflow holding
|
||||
credentials that can POST a commit status can still forge `review-verdict/h10`;
|
||||
`docker-build.yml` demonstrably can, and must stay head-resolved because it builds the PR's own
|
||||
code. Tracked in #697. So the "careless change rather than a hostile one" posture below still
|
||||
`docker-build.yml` demonstrably could, and must stay head-resolved because it builds the PR's own
|
||||
code — so #697 scoped its credential instead (`ci.actions-credential-scoping`), leaving AT LEAST
|
||||
these: the injected `GITEA_TOKEN` (posts with `creator: null`), `RENOVATE_TOKEN` (a
|
||||
`write:repository` bot PAT in the same secret store, so it posts with a real creator and IS
|
||||
inherited, #742), a collaborator's own token, and the `v*` tag push — which matters less for
|
||||
forging this status than for what else it does: `docker-build.yml` publishes `:prod` from a tagged
|
||||
ref, and a tag may point at any commit, so it ships a prod image with no PR, review or status.
|
||||
None of which used to be even required — direct pushes to `main` were server-side permitted, so
|
||||
the gate could be skipped without forging anything (#743). **That route is now closed**
|
||||
(`release.main-direct-push-disabled`): `main` carries `enable_push: false` *and*
|
||||
`block_admin_merge_override: true`, so every change reaches `main` through the PR merge path,
|
||||
which is the only path on which these required contexts are evaluated. What survives is the
|
||||
forgery list above — those routes post a status rather than skip it, so they are still real —
|
||||
**plus one skip route that is not forgery at all**: a credential that can `PATCH` branch
|
||||
protection can turn either field off, act, and restore it. `timothy` is a site admin, so every
|
||||
session holds that capability; it is an accepted residual, recorded in
|
||||
`release.main-direct-push-disabled` and `ci.actions-credential-scoping`, not a closed route. So
|
||||
the "careless change rather than a hostile one" posture below still
|
||||
describes the repo accurately — it is simply no longer *this* workflow that is the weakest link.
|
||||
An untrusted-contributor repo would still need the classification moved somewhere no PR can
|
||||
reach (server-side policy), not merely a base-pinned definition.
|
||||
|
||||
+41
-3
@@ -186,12 +186,50 @@ Re-adding an already-present item is an **idempotent no-op** (no duplicate rows,
|
||||
referenced id does not exist the whole batch is rejected (`422`). So the flow is: search → add ids →
|
||||
re-run to confirm idempotence.
|
||||
|
||||
### Full-replace writes drop what you omit (`mcp.tool-schema-openapi-parity`)
|
||||
|
||||
**Check each tool's own description — the write tools are not uniform, and one is not uniform with
|
||||
itself.** Three are full replaces, where a field you leave out is not "left unchanged" but written as
|
||||
empty: `ersatztv_update_channel`, `ersatztv_update_schedule`, `ersatztv_update_collection_custom_order`.
|
||||
|
||||
`ersatztv_update_playout` is **mixed, and this is the easy one to get wrong**: `scheduleFile` is
|
||||
leave-unchanged, but `dailyRebuildTime` is always applied — `UpdatePlayoutHandler` sets it to `null`
|
||||
unconditionally before re-applying a supplied value, so calling this tool to set `scheduleFile` while
|
||||
omitting `dailyRebuildTime` **silently clears the daily reset**. Send both, or neither.
|
||||
|
||||
The rest are additive or leave-unchanged and say so: `ersatztv_add_collection_items` is an idempotent
|
||||
add (it does **not** replace membership), `ersatztv_update_collection` leaves an omitted
|
||||
`useCustomPlaybackOrder` alone, and `ersatztv_enable_jellyfin_library_sync` leaves an absent row
|
||||
untouched.
|
||||
|
||||
For the full-replace ones, the GET → edit one field → PUT flow is only safe if the tool can express
|
||||
the whole state, and `ersatztv_update_channel` could not — it omitted `graphicsElementIds`, so that flow
|
||||
silently detached every graphics element (including the On Now/Next overlay) with a `200` and no
|
||||
error, visible only as missing pixels at the next transition. `ersatztv_update_schedule` cleared
|
||||
`padToNearestMinute` the same way (ersatztv#754).
|
||||
|
||||
Both are fixed, and the class is now guarded by two tests in `ToolCatalogTests`, comparing against the
|
||||
generated `ErsatzTV/wwwroot/openapi/v1.json`:
|
||||
|
||||
- Every POST/PUT/PATCH tool declares **exactly** the request-body fields its endpoint accepts, each
|
||||
with a matching type. A new property on a request DTO fails until the catalog declares it.
|
||||
- Every tool — read **and** write — declares **exactly** its endpoint's query parameters. An omitted
|
||||
one is not merely undocumented but *unreachable*, since `ToolArgumentValidator` rejects undeclared
|
||||
arguments; that is how #616 hard-capped two paged tools at the first page, and how
|
||||
`ersatztv_list_playouts` (`query`) and `ersatztv_get_playout_items` (`showFiller`) lost their
|
||||
filters until ersatztv#757.
|
||||
|
||||
When adding a write tool, regenerate the spec (`./scripts/update-openapi.sh`) and add the tool to the
|
||||
pinned list in the body test.
|
||||
|
||||
## Deferred
|
||||
|
||||
Channel create/update (`ersatztv_create_channel` / `ersatztv_update_channel`) wrap a 28-field DTO with
|
||||
nine enum fields. Only `name`/`number`/`ffmpegProfileId` are required; the rest have server-side
|
||||
Channel create/update (`ersatztv_create_channel` / `ersatztv_update_channel`) wrap a large DTO with
|
||||
nine enum fields — 27 body fields on create, and 28 on update, which additionally carries
|
||||
`graphicsElementIds`. Only `name`/`number`/`ffmpegProfileId` are required; the rest have server-side
|
||||
defaults, and the enum fields take the enum **name** (the API validates them). Discover an existing
|
||||
channel's shape and current enum values with `ersatztv_get_channel` before creating/updating.
|
||||
channel's shape and current enum values with `ersatztv_get_channel` before creating/updating — and
|
||||
copy its `graphicsElementIds` through unless you mean to detach them.
|
||||
|
||||
Deliberately **not** exposed in this cautious first write pass:
|
||||
|
||||
|
||||
+36
-4
@@ -178,10 +178,42 @@ the #644 follow-up got Class A right and Class B only half right):
|
||||
|
||||
The helper owns both bounds: at most ONE `getLibraryBrowseItems` request per settled query, at most
|
||||
`LIBRARY_PICKER_RESULTS` (25) rows, and no request at all below `LIBRARY_PICKER_MIN_QUERY` (2)
|
||||
characters. Selecting a media-library type must issue **zero** requests. There is no truncation, so
|
||||
there is no truncation hint — the old `Showing the first 100 of 5000 — use search to narrow.` copy
|
||||
is gone from these pickers along with the window it described. Prove the bound with a
|
||||
**request-count assertion against a large (20k-row) fixture**, not by inspection.
|
||||
characters. Selecting a media-library type must issue **zero** requests. The per-kind
|
||||
`LIBRARY_PICKER_RESULTS` cap is the only truncation this class has — there is no whole-type window
|
||||
left to hint at, so the old `Showing the first 100 of 5000 — use search to narrow.` copy is gone
|
||||
from these pickers along with the window it described. Surfacing the per-kind cap is *permitted*
|
||||
wherever it is reachable, and *required* only where bulk selection makes the count actionable — see
|
||||
the `AddItemsDialog` sub-bullet below, which sums the cap across kinds and renders a `Showing N of
|
||||
M matches` hint for exactly that reason. Prove the bound with a **request-count assertion against a
|
||||
large (20k-row) fixture**, not by inspection.
|
||||
|
||||
- **`SearchPicker` is the single-select SHAPE, not the rule itself.** A MULTI-select picker
|
||||
(`CollectionsScreen`'s `AddItemsDialog` — checkbox rows, many items added at once, fanned out
|
||||
over several kinds) cannot render `SearchPicker` and must not be forced to. It satisfies this
|
||||
section by taking the same *constraints* the helper enforces for single-select — the gate on
|
||||
`LIBRARY_PICKER_MIN_QUERY`, `titleContainsQuery` the typed text, `LIBRARY_PICKER_RESULTS` per
|
||||
kind — via `searchLibraryBrowseItems` (`web/src/api/libraryBrowse.ts`), a sibling of
|
||||
`searchLibraryPickerOptions` that returns full `LibraryBrowseItem` rows plus `totalCount`
|
||||
instead of `{id, name}`, so the bound lives in the helper rather than the caller (#685 review
|
||||
finding 2). There is no post-fetch `slice`, but the per-kind cap can still truncate the real
|
||||
match count — this is a bulk multi-select add, where "add the 40 matching episodes" is a
|
||||
first-class use, so `AddItemsDialog` sums each kind's `totalCount` and renders a `Showing N of
|
||||
M matches` hint once it exceeds the rendered rows (finding 4 — an earlier revision of this
|
||||
bullet called the truncation nothing left to hint at). **The gate's home is the shared HELPER,
|
||||
not the screen — however single-sink the screen's own function looks.** #685 got this wrong
|
||||
twice in a row, and the second time is the instructive one: the check sat inside `runSearch`,
|
||||
which genuinely IS the one sink both entry paths route through, so it read as correct. It was
|
||||
still a duplicate of the helper's gate, and the two masked each other: as of `4be3f247d` —
|
||||
which had no unit tests on the helper — deleting EITHER copy left the whole suite green, so the
|
||||
min-query boundary test pinned nothing. Removing the screen's copy is what made the helper's
|
||||
gate load-bearing. **The invariant, not the count: every gate must have at least one test that
|
||||
reddens when that gate ALONE is removed.** A guard you cannot redden is not a guard, and "it's
|
||||
the single sink" is not evidence that it is the only one. **Outstanding on this screen**: `AddItemsDialog` still lacks the monotonic `seqRef`
|
||||
stale-response guard and `useIsMountedRef()` — the same class of guard "Debounced typeaheads"
|
||||
below mandates there, applied to a debounced-while-typing fetch; `AddItemsDialog` is an explicit
|
||||
Search-button submission, not a typeahead, so that mandate doesn't reach it directly, but the
|
||||
same race (a superseded search settling after a newer one) can still occur here — tracked in
|
||||
**ersatztv#740**, not yet fixed here.
|
||||
|
||||
- **Compile typed text; never forward raw Lucene.** Send `titleContainsQuery(text)` →
|
||||
`title:*<escaped>*`. The index's default field does not match bare title words (`Alpha` finds
|
||||
|
||||
Executable
+247
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env bash
|
||||
# Per-step execution markers for the two REQUIRED docker-build.yml jobs (ersatztv#756).
|
||||
#
|
||||
# WHY THIS EXISTS. A `run:` body the runner declines to interpolate is DROPPED, and the job still
|
||||
# concludes `success` (ersatztv#751, `ci.workflow-run-body-no-expressions`). In
|
||||
# `review-verdict.yml` that is fail-CLOSED — the required `review-verdict/h10` is simply absent and
|
||||
# the merge is blocked. In `docker-build.yml` it is fail-OPEN: `Build & test (.NET)` and
|
||||
# `EF migration integrity (SQLite + MySql)` are the other two required contexts on `main`, so a
|
||||
# dropped step there sends a required check GREEN having done no work. #751 guarded the safe
|
||||
# direction because that is where the live bug was, not because these were checked.
|
||||
#
|
||||
# WHY PER STEP, NOT PER JOB, which is what #756 proposed. A marker written by the job's FIRST step
|
||||
# only proves the job started. The dangerous drop is not step 1 — it is `Test`, or the migration
|
||||
# replay: the job runs everything around them, reports green, and nothing ran that anyone cared
|
||||
# about. A guard that cannot see the fail-open case it was built for is the "guard that never
|
||||
# executed" failure one level up. So every consequential step marks itself and a trailing guard
|
||||
# asserts the whole expected SET.
|
||||
#
|
||||
# THAT GUARD CARRIES NO `if:` — unlike the #751 one, which uses `if: always()` because its job has a
|
||||
# single real step. These jobs have a dozen, and a genuine early failure legitimately skips every
|
||||
# later step, so `always()` would print a false "these steps never executed" on top of every ordinary
|
||||
# red build. The default `success()` is the wanted condition: the guard is skipped only when an
|
||||
# earlier step FAILED, which already fails the job, so guard-skipped implies job-red and every green
|
||||
# path runs the guard.
|
||||
#
|
||||
# WHY A SCRIPT AND NOT AN INLINE BODY, unlike the #751 guard. Two reasons, and the second is the
|
||||
# load-bearing one:
|
||||
#
|
||||
# * The path literal exists ONCE. The #751 guard carries it twice (write + assert) and its tests
|
||||
# spend real effort proving the two copies agree, because a divergence reddens every run and
|
||||
# then gets deleted as broken. Here they cannot diverge.
|
||||
# * A one-line `run: scripts/ci-step-ran.sh …` CANNOT CONTAIN AN EXPRESSION DELIMITER, so the
|
||||
# mechanism this guards against cannot drop the guard itself. #751's own record names this as
|
||||
# the stronger construction ("the body would have had to move into scripts/, where a one-line
|
||||
# run: makes the class unreachable") and settled for inline only because the measurement showed
|
||||
# it was not required there.
|
||||
#
|
||||
# WHY A SCRIPT IS ACCEPTABLE HERE THOUGH IT WOULD NOT BE IN review-verdict.yml. That workflow
|
||||
# checks out the PR's BASE precisely so a PR cannot supply the code that judges it. `docker-build.yml`
|
||||
# is head-resolved by design — a PR already supplies every test this job runs — so calling a script
|
||||
# from the head adds no authority a PR did not already have. This is a CORRECTNESS gate against
|
||||
# silent no-ops, not a security gate against a hostile PR; that job belongs to `review-verdict/h10`.
|
||||
# Do not copy this reasoning back into the gate workflow.
|
||||
#
|
||||
# THE MARKER FILE IS KEYED ON THE RUN, and BE PRECISE ABOUT WHY — the obvious justification is a
|
||||
# #751 measurement that does NOT transfer to these jobs, and saying so is the point. #751 measured
|
||||
# `RUNNER_TEMP` to be `/tmp` and called it "not a private per-job directory"; that was taken on
|
||||
# `review-verdict.yml`, which runs WITHOUT a `container:`. `test` and `migrations` run INSIDE the CI
|
||||
# toolchain image, so their `/tmp` is the job container's own and starts empty. That follows from
|
||||
# `container:`, NOT from a measurement: the build-lane probe confirmed only that `RUNNER_TEMP` is
|
||||
# `/tmp` here (the marker landed at `/tmp/etv-ci-steps-ran-test-1910-1`) — it says nothing about the
|
||||
# directory being private or empty, and an earlier draft of this comment cited it as though it did.
|
||||
# The fresh container is what actually rules out a stale marker here; the keying is defence in depth.
|
||||
#
|
||||
# It is kept because container-per-job is a property of how the lane is configured today, not a
|
||||
# guarantee, and a STALE marker is the one failure that makes this guard PASS on a run whose step was
|
||||
# dropped — a silent success, i.e. the exact thing being removed. Cheap insurance against a lane
|
||||
# change nobody would think to re-check this against.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage:
|
||||
ci-step-ran.sh mark <key>
|
||||
Record that this step began executing. Call it as the step's FIRST act, before
|
||||
anything in the body can fail.
|
||||
|
||||
ci-step-ran.sh assert --always <key>... [--gated <key>...]
|
||||
Fail unless every expected key was marked. --always keys are always required.
|
||||
--gated keys are required only when the job's skip gates did NOT fire, read from
|
||||
ETV_DOCS_ONLY / ETV_REVALIDATE_SKIP so this mirrors the steps' own `if:`.
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
# NO SILENT FALLBACK FOR THE RUN IDENTITY — found by cold review. The first version defaulted to
|
||||
# `nojob`/`norunid`/`1`, and those are REUSABLE: with `GITHUB_RUN_ID` unset, every run on the host
|
||||
# would share ONE marker file, so a leftover from any earlier run would satisfy the guard on a run
|
||||
# whose step was dropped. A silent PASS — the exact failure the keying exists to remove, reintroduced
|
||||
# by the code meant to implement it.
|
||||
#
|
||||
# THE TWO HALVES ARE TREATED DIFFERENTLY, ON EVIDENCE, because the blast radii differ and this is a
|
||||
# REQUIRED check — a wrong refusal deadlocks every merge, so strictness is not free:
|
||||
#
|
||||
# * `GITHUB_JOB` and `GITHUB_RUN_ID` are MEASURED present on this runner (#756's build-lane probe
|
||||
# wrote `/tmp/etv-ci-steps-ran-test-1910-1`; `test` is the job id and 1910 is the real API run
|
||||
# id). Absence would mean the runner changed under us, so refusing is safe AND correct.
|
||||
# * `GITHUB_RUN_ATTEMPT` is measured present TOO, as of ersatztv#756's own PR run — but note how,
|
||||
# because the first two attempts to settle it were both bad. Grepping a job log for the variable
|
||||
# NAME proves nothing (logs do not dump the environment). Inferring it from the ABSENCE of this
|
||||
# script's "not set" warning proves nothing either, because that warning goes to stderr and
|
||||
# whether step stderr reaches a job log here was itself never established. So the script was made
|
||||
# to REPORT its resolved identity on stdout, where capture is not in question, and the answer was
|
||||
# then simply read off run 1916: `Marker identity: job=test run=1916 attempt=1 (from the runner)`
|
||||
# and the same for `migrations`. Both required jobs, on the lane that matters.
|
||||
#
|
||||
# That measurement is what promoted it from warn-and-default to REQUIRED, which is why the residual
|
||||
# this comment used to describe — a rerun inheriting attempt 1's markers — no longer exists. If a
|
||||
# future runner stops exporting any of the three, every job reddens with a message naming the
|
||||
# variable; that is loud, instantly diagnosable, and the correct direction for a required check.
|
||||
marker_path() {
|
||||
local missing=""
|
||||
[ -n "${GITHUB_JOB:-}" ] || missing="$missing GITHUB_JOB"
|
||||
[ -n "${GITHUB_RUN_ID:-}" ] || missing="$missing GITHUB_RUN_ID"
|
||||
[ -n "${GITHUB_RUN_ATTEMPT:-}" ] || missing="$missing GITHUB_RUN_ATTEMPT"
|
||||
if [ -n "$missing" ]; then
|
||||
# NOTHING IS PRINTED TO STDOUT HERE, and that is load-bearing rather than style: this
|
||||
# function's stdout IS its return value (it is always called inside `$( )`), so a notice
|
||||
# printed here is captured INTO the path. An earlier revision did exactly that and both
|
||||
# sub-commands then failed on a nonexistent directory. Caught by
|
||||
# test_a_degraded_run_IDENTITY_*, which is why that test asserts on the exit status and on
|
||||
# the absence of any marker file rather than only on the message.
|
||||
echo "::error::ci-step-ran.sh cannot identify this run —${missing} not set. The marker path would fall back to a name other runs also use, and a stale marker would make the dropped-step guard PASS on a run whose step never executed (ersatztv#756). Refusing rather than degrading to a reusable name." >&2
|
||||
exit 3
|
||||
fi
|
||||
printf '%s/etv-ci-steps-ran-%s-%s-%s' \
|
||||
"${RUNNER_TEMP:-${GITHUB_WORKSPACE:-/tmp}}" \
|
||||
"$GITHUB_JOB" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT"
|
||||
}
|
||||
|
||||
cmd_mark() {
|
||||
[ "$#" -eq 1 ] && [ -n "$1" ] || usage
|
||||
# Appended, never truncated: every step in the job shares one file, and a `>` here would erase
|
||||
# its predecessors and make the guard red on every run.
|
||||
#
|
||||
# A failure to write is NOT swallowed. The step is running under `bash -e`, so a non-zero here
|
||||
# fails the step and reddens the job — which is the same direction the guard would take a moment
|
||||
# later, but with a message pointing at the real cause instead of at a missing marker.
|
||||
local target
|
||||
# NOT `>> "$(marker_path)"`: the refusal above `exit`s a SUBSHELL there, and bash discards a
|
||||
# command substitution's exit status when it is only part of a redirection — the write would go
|
||||
# to an empty path and the error would read as a redirection failure rather than the real cause.
|
||||
target="$(marker_path)" || exit $?
|
||||
printf '%s\n' "$1" >> "$target"
|
||||
}
|
||||
|
||||
cmd_assert() {
|
||||
local -a always=() gated=()
|
||||
local bucket=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--always) bucket=always ;;
|
||||
--gated) bucket=gated ;;
|
||||
-*) usage ;;
|
||||
*)
|
||||
case "$bucket" in
|
||||
always) always+=("$1") ;;
|
||||
gated) gated+=("$1") ;;
|
||||
*) usage ;;
|
||||
esac ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
# ANTI-VACUITY, at runtime rather than only in the test suite. An `assert` called with no
|
||||
# expectations passes unconditionally and reports "every expected step executed" — a guard that
|
||||
# proves nothing while looking like it proved everything. Refuse instead.
|
||||
if [ "${#always[@]}" -eq 0 ] && [ "${#gated[@]}" -eq 0 ]; then
|
||||
echo "::error::ci-step-ran.sh assert was called with no expected keys, so it would pass unconditionally. This is a workflow bug, not a build failure." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# The skip gates, mirroring the `if:` every gated step carries:
|
||||
# steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
# Anything other than the exact string `true` means the step was expected to run — including the
|
||||
# EMPTY string, which is what these read as when the detect step itself was dropped. That
|
||||
# direction is deliberate: a dropped detect step must widen what is required, never narrow it.
|
||||
local skipped=no
|
||||
if [ "${ETV_DOCS_ONLY:-}" = "true" ] || [ "${ETV_REVALIDATE_SKIP:-}" = "true" ]; then
|
||||
skipped=yes
|
||||
fi
|
||||
|
||||
local marker attempt_used
|
||||
# `|| exit $?` because `set -e` does NOT fire on a failing command substitution in an assignment;
|
||||
# without it a degraded identity would leave `marker` empty and every key would read as missing —
|
||||
# fail-closed by luck, with a misleading message.
|
||||
marker="$(marker_path)" || exit $?
|
||||
# Read the attempt back OFF THE RESOLVED PATH rather than from the environment. It reports what
|
||||
# the path was actually keyed on, so a future change to how the path is built cannot silently
|
||||
# disagree with the line that documents it.
|
||||
attempt_used="${marker##*-}"
|
||||
# `${arr[@]+"${arr[@]}"}` rather than a bare `"${arr[@]}"`: under `set -u` bash 3.2 (the system
|
||||
# bash on the Macs this suite also runs on) treats expanding an EMPTY array as an unbound
|
||||
# variable and aborts. The CI image ships bash 5, where it is fine — which is exactly the kind of
|
||||
# difference that makes a guard pass locally and die on the runner, or the reverse.
|
||||
local -a expected=(${always[@]+"${always[@]}"})
|
||||
if [ "$skipped" = no ]; then
|
||||
expected+=(${gated[@]+"${gated[@]}"})
|
||||
else
|
||||
echo "Skip gate fired (docs_only='${ETV_DOCS_ONLY:-}', already_validated='${ETV_REVALIDATE_SKIP:-}') — the gated steps were not expected to run."
|
||||
fi
|
||||
|
||||
# RE-CHECKED AFTER GATING, not only on argv — found by cold review, which reproduced it:
|
||||
# `ETV_DOCS_ONLY=true … assert --always --gated foo` printed "All 0 expected step(s) executed"
|
||||
# and exited 0. The argv check above cannot see that, because the set is emptied by the gate, not
|
||||
# by the caller. Unreachable with today's argv (both jobs pass `--always detect revalidate`), but
|
||||
# it directly contradicted the comment above it, and a guard that reports proving everything
|
||||
# while proving nothing is the failure this whole file exists to remove.
|
||||
if [ "${#expected[@]}" -eq 0 ]; then
|
||||
echo "::error::ci-step-ran.sh assert ended up with NO expected keys after the skip gate, so it would pass unconditionally. This is a workflow bug, not a build failure." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
local -a missing=()
|
||||
local key
|
||||
for key in "${expected[@]}"; do
|
||||
# `grep -qxF` over a FILE, never a pipeline: `grep -q` exits at its first match and would
|
||||
# SIGPIPE a producer, which under `set -o pipefail` inverts the result for large inputs
|
||||
# (ersatztv#698). Reading the file directly has no producer to kill. `-x` so a key cannot be
|
||||
# satisfied by another key that contains it, `-F` so a key is never read as a pattern.
|
||||
if ! grep -qxF "$key" "$marker" 2>/dev/null; then
|
||||
missing+=("$key")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
echo "::error::These steps of job '${GITHUB_JOB:-?}' never executed: ${missing[*]}. The runner DROPPED them (an interpolation failure over a run: body does this and still reports the job GREEN — ersatztv#751/#756) or their \`if:\` no longer matches the guard's expectations. This job is a REQUIRED check, so a green here would mean a required context passed having done no work. Failing the job so it is visible."
|
||||
if [ -f "$marker" ]; then
|
||||
echo "Marker file ${marker} recorded:"
|
||||
sed 's/^/ /' "$marker"
|
||||
else
|
||||
echo "There is no marker file at ${marker} at all — not one step of this job executed."
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
# The resolved identity, on stdout, every run. This is what turns "is GITHUB_RUN_ATTEMPT
|
||||
# exported here?" from an inference into something a reader just looks up — and it is why the
|
||||
# variable is still WARN-and-default rather than REFUSE: `GITHUB_JOB` and `GITHUB_RUN_ID` have
|
||||
# positive evidence (the probe's marker filename), this one does not yet, and refusing on an
|
||||
# unestablished variable would redden a REQUIRED check. Promote it once a run has printed
|
||||
# `attempt=<n> (from the runner)`.
|
||||
# Kept after the promotion, though all three components are now required and the line can no
|
||||
# longer report anything but the runner's own values. It is the standing evidence: this is the
|
||||
# line that settled whether GITHUB_RUN_ATTEMPT is exported, and it is what a future reader checks
|
||||
# first if the keying is ever doubted again.
|
||||
echo "Marker identity: job=${GITHUB_JOB} run=${GITHUB_RUN_ID} attempt=${attempt_used} (from the runner)"
|
||||
echo "All ${#expected[@]} expected step(s) executed: ${expected[*]}"
|
||||
}
|
||||
|
||||
[ "$#" -ge 1 ] || usage
|
||||
sub="$1"
|
||||
shift
|
||||
case "$sub" in
|
||||
mark) cmd_mark "$@" ;;
|
||||
assert) cmd_assert "$@" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
@@ -0,0 +1,723 @@
|
||||
"""The dropped-step guard on docker-build.yml's two REQUIRED jobs (ersatztv#756).
|
||||
|
||||
WHAT THIS IS PROTECTING. A `run:` body the runner declines to interpolate is DROPPED, and the job
|
||||
still concludes `success` (ersatztv#751, `ci.workflow-run-body-no-expressions`). #751 fixed that in
|
||||
`review-verdict.yml`, where the consequence is fail-CLOSED — `review-verdict/h10` is absent and the
|
||||
merge is blocked. It left the two places where the same drop is fail-OPEN: `Build & test (.NET)` and
|
||||
`EF migration integrity (SQLite + MySql)` are the other two required contexts on `main`, so a dropped
|
||||
step there sends a required check green having done no work.
|
||||
|
||||
THE TESTS COME IN THREE KINDS AND NONE SUBSTITUTES FOR ANOTHER, which is the lesson #751 paid for:
|
||||
|
||||
* STATIC — the marker set and the guard's expectations agree, and the guard is positioned so it
|
||||
can actually run. Cheap, and the only kind that catches a NEW step added without a marker.
|
||||
* BEHAVIOURAL — the guard's real command line is EXECUTED against markers written by the steps'
|
||||
real marker lines, both extracted from the parsed workflow. A structural test cannot prove an
|
||||
exit code, and `exit 1` in a body is satisfiable by dead code.
|
||||
* A LIVE PROBE — that the runner still executes a LATER step after dropping an earlier one, on the
|
||||
BUILD lane rather than the `small` lane #751 measured. That is the premise the whole guard rests
|
||||
on and no test here can establish it; it is recorded in docs/ci-cd.md and on the issue.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "docker-build.yml"
|
||||
SCRIPT = REPO_ROOT / "scripts" / "ci-step-ran.sh"
|
||||
|
||||
# The jobs whose contexts branch protection REQUIRES on `main`. Read live on 2026-08-10:
|
||||
# Build ErsatzTV Image / Build & test (.NET) (pull_request)
|
||||
# Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request)
|
||||
# review-verdict/h10
|
||||
# The third is guarded by test_pr_changed_files.py; these two are this file's subject. `build`,
|
||||
# `api-docs` and `format` are deliberately NOT here — they are not required, and all three
|
||||
# legitimately interpolate into a `run:` body, so extending the absolute rule to them would be false.
|
||||
# Per-step markers apply to the two REQUIRED contexts, where a dropped step is fail-OPEN.
|
||||
MARKED_JOBS = ("test", "migrations")
|
||||
|
||||
# The delimiter ban is WIDER than the marker set, and the extra job is not an afterthought.
|
||||
# `build`'s "Smoke + IPTV E2E" step runs AFTER `Build and push`, so on a `v*` tag the image is
|
||||
# already in the registry as the release candidate and this step is what decides whether it was ever
|
||||
# booted. A drop there publishes an unsmoked candidate and goes green, and `DeployStack jazz-media`
|
||||
# promotes exactly that image — not a "smaller cost than a required context", which is what an
|
||||
# earlier draft of the decision record claimed. Its two payloads moved into the step's `env:`, which
|
||||
# is the free half of the escape hatch, so the ban costs nothing there.
|
||||
#
|
||||
# `functional-e2e` is deliberately NOT here even though it is delimiter-free today: it is advisory by
|
||||
# declaration (not a required check, not a `needs:` of `build`), so the rule stays "ban where a drop
|
||||
# is consequential" rather than "ban wherever it happens to be free right now".
|
||||
# `api-docs` and `format` keep one delimiter each, both `github.base_ref` in a detect step, and gate
|
||||
# nothing that ships.
|
||||
DELIMITER_BAN_JOBS = ("test", "migrations", "build")
|
||||
|
||||
# THE RAW OPENER, not a closed `${{ … }}` pair — found by cold review. The runner's rewrite is
|
||||
# triggered by the OPENER; a closed-pair regex therefore misses `# ${{` with no closer, which would
|
||||
# sail through an "absolute" ban and still drop the step. Nothing in these jobs may contain the
|
||||
# opener at all, so matching it directly is both simpler and strictly stronger. `_EXPR` is kept for
|
||||
# reporting the payload of a well-formed one in the failure message.
|
||||
_OPENER = re.compile(r"\$\{\{")
|
||||
_EXPR = re.compile(r"\$\{\{(.*?)\}\}", re.S)
|
||||
_MARK = re.compile(r'ci-step-ran\.sh"?\s+mark\s+(\S+)')
|
||||
|
||||
|
||||
# ONE parse, shared. `yaml.safe_load` per call returns a fresh object graph, so an identity test
|
||||
# across two helpers (`steps[-1] is guard`) would compare structurally-equal but distinct dicts and
|
||||
# fail — or, worse in the other direction, an `is not` filter would exclude nothing and a step would
|
||||
# match as its own guard. That is not hypothetical: test_pr_changed_files.py records exactly this
|
||||
# going wrong in the #751 guard test, where the assertions then ran against the wrong step.
|
||||
_DOC = yaml.safe_load(WORKFLOW.read_text())
|
||||
|
||||
|
||||
def _doc():
|
||||
return _DOC
|
||||
|
||||
|
||||
def _steps(job: str):
|
||||
return _doc()["jobs"][job]["steps"]
|
||||
|
||||
|
||||
def _run_steps(job: str):
|
||||
return [s for s in _steps(job) if s.get("run")]
|
||||
|
||||
|
||||
def _guard(job: str):
|
||||
"""The trailing assert step. Located by CONTENT, never by index.
|
||||
|
||||
Locating it as `steps[-1]` here and then asserting it is last elsewhere would be circular — the
|
||||
position test would hold by construction. This finds the step that invokes the assert
|
||||
sub-command, and `test_the_guard_is_the_LAST_step` independently checks where it sits.
|
||||
"""
|
||||
hits = [s for s in _run_steps(job) if "ci-step-ran.sh assert" in s["run"]]
|
||||
assert len(hits) == 1, f"job '{job}' has {len(hits)} assert steps, expected exactly 1"
|
||||
return hits[0]
|
||||
|
||||
|
||||
def _marked(job: str):
|
||||
"""[(step, key)] for every step that records its own execution, in declaration order."""
|
||||
out = []
|
||||
for s in _run_steps(job):
|
||||
m = _MARK.search(s["run"])
|
||||
if m:
|
||||
out.append((s, m.group(1)))
|
||||
return out
|
||||
|
||||
|
||||
def _guard_buckets(job: str):
|
||||
"""(always_keys, gated_keys) as the guard's own argv spells them."""
|
||||
argv = _guard(job)["run"].split()
|
||||
assert "--always" in argv and "--gated" in argv, argv
|
||||
a, g = argv.index("--always"), argv.index("--gated")
|
||||
return argv[a + 1:g], argv[g + 1:]
|
||||
|
||||
|
||||
# Mirrors the `if:` every gated step in these jobs carries. Compared as a normalised string rather
|
||||
# than by parsing the expression: what matters is that a step's gating and the guard's bucketing are
|
||||
# the SAME condition, and any rewrite of one that is not mirrored in the other should be loud.
|
||||
SKIP_GATE = ("steps.detect.outputs.docs_only!='true'&&steps.revalidate.outputs.skip!='true'")
|
||||
|
||||
|
||||
def _is_gated(step) -> bool:
|
||||
return re.sub(r"\s+", "", str(step.get("if", ""))) == SKIP_GATE
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
# STATIC
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("job", DELIMITER_BAN_JOBS)
|
||||
def test_the_delimiter_banned_jobs_have_NO_expression_delimiter_in_any_run_body(job):
|
||||
"""The absolute rule from `review-verdict.yml`, extended to the two required build jobs.
|
||||
|
||||
This is the cheaper and more general half of #756: the drop mechanism REQUIRES an opener in the
|
||||
scalar, so a job with none is immune by construction and the runtime markers are a backstop
|
||||
rather than the only line of defence.
|
||||
|
||||
The scope is the three jobs in `DELIMITER_BAN_JOBS` — see the comment there for why `build` is in
|
||||
and `functional-e2e` is not. Do NOT restate this docstring as "scoped to the required pair":
|
||||
round 2 moved `build`'s two payloads into `env:` and brought it into the ban, and this docstring
|
||||
sits directly above the decorator that parametrises over the wider set.
|
||||
|
||||
The escape hatch when a value really is needed is the step's `env:` block, which is interpolated
|
||||
PER VALUE, so a payload that does not evaluate cannot take the body with it.
|
||||
|
||||
The `run:` SCALAR AS PARSED, comments and all. A shell comment inside a `run:` body is NOT inert
|
||||
— that is the whole #751 defect — so this must never filter comments out. Ordinary YAML comments
|
||||
outside a `run:` body ARE inert and are not read here.
|
||||
"""
|
||||
offenders = []
|
||||
for s in _run_steps(job):
|
||||
for m in _OPENER.finditer(s["run"]):
|
||||
closed = _EXPR.match(s["run"], m.start())
|
||||
payload = closed.group(1).strip() if closed else "<unclosed opener>"
|
||||
offenders.append(f"{s.get('name', '?')}: {payload!r}")
|
||||
assert not offenders, (
|
||||
f"job '{job}' of docker-build.yml has an expression delimiter inside a run: body — "
|
||||
f"{offenders}. A dropped step in this job is CONSEQUENTIAL — `test`/`migrations` write "
|
||||
"REQUIRED status contexts, and `build` publishes the release candidate before its smoke step "
|
||||
"runs. Even in a comment a delimiter is unsafe: the runner rewrites the WHOLE body into a "
|
||||
"format(...) call, and if the payload does not parse it DROPS THE STEP and reports the job "
|
||||
"green — so the check passes having done no work (ersatztv#751/#756). Pass the value in "
|
||||
"through the step's `env:` "
|
||||
"block instead; to describe an expression in prose, name it rather than quoting the "
|
||||
"delimiters."
|
||||
)
|
||||
# ANTI-VACUITY. A walk that reached no bodies, or only the trivial ones, would make the
|
||||
# assertion above green while proving nothing. Counted against the job's own step list read
|
||||
# here, so a helper that silently stopped yielding steps is caught rather than rewarded.
|
||||
declared = sum(1 for s in _steps(job) if isinstance(s, dict) and s.get("run"))
|
||||
assert len(_run_steps(job)) == declared >= 3, (
|
||||
f"the walk reached {len(_run_steps(job))} run: bodies but job '{job}' declares {declared}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("job", MARKED_JOBS)
|
||||
def test_every_consequential_run_step_marks_itself_as_its_FIRST_act(job):
|
||||
"""The completeness half — and the only test that catches a NEWLY ADDED step with no marker.
|
||||
|
||||
A guard that checks a fixed list can go quietly incomplete: someone adds a `Test SPA (part 2)`
|
||||
step, it is never marked, the guard never expects it, and a drop of exactly that step is
|
||||
invisible again. So the expectation is DERIVED from the workflow rather than written down twice.
|
||||
|
||||
EXEMPT: steps carrying `continue-on-error: true`. Those are advisory by construction (the
|
||||
peak-anon sampler, the coverage summary) — the workflow already declares that their failure must
|
||||
not redden the job, so their non-execution cannot be a fail-open either. Making them mandatory
|
||||
would be asserting the opposite of what `continue-on-error` means.
|
||||
|
||||
FIRST ACT, not merely present. A marker written at the END of a body records completion, not
|
||||
execution — and this repo has legitimate early-exit paths. More importantly a marker further down
|
||||
can be skipped by an early `exit 0` while the step did nothing, which is the fail-open again one
|
||||
line lower. `set -euo pipefail` is allowed to precede it: it cannot fail, and it is what makes
|
||||
the rest of the body honest.
|
||||
"""
|
||||
missing, late = [], []
|
||||
for s in _run_steps(job):
|
||||
if s.get("continue-on-error") is True or "ci-step-ran.sh assert" in s["run"]:
|
||||
continue
|
||||
m = _MARK.search(s["run"])
|
||||
if not m:
|
||||
missing.append(s.get("name", "?"))
|
||||
continue
|
||||
# By LINE, not by byte offset. The marker sits mid-line (the command is quoted and
|
||||
# prefixed with $GITHUB_WORKSPACE), so slicing at `m.start()` counts the marker's OWN line
|
||||
# prefix as a preceding command and reddens every correctly-written step.
|
||||
lines = s["run"].splitlines()
|
||||
at = next(i for i, ln in enumerate(lines) if _MARK.search(ln))
|
||||
preceding = [
|
||||
ln.strip() for ln in lines[:at]
|
||||
if ln.strip() and not ln.strip().startswith("#")
|
||||
]
|
||||
if [ln for ln in preceding if not ln.startswith("set -")]:
|
||||
late.append((s.get("name", "?"), preceding))
|
||||
assert not missing, (
|
||||
f"these run: steps of the REQUIRED job '{job}' do not record that they executed: {missing}. "
|
||||
"A step the runner drops concludes success, so without a marker its non-execution takes the "
|
||||
"whole required context green having done no work (ersatztv#756). Add "
|
||||
'`\"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh\" mark <key>` as the step\'s first line and '
|
||||
"the key to the guard step's --always/--gated list."
|
||||
)
|
||||
assert not late, (
|
||||
f"these steps of '{job}' mark themselves only after other commands have run: {late}. The "
|
||||
"marker must be the first act, or a body that exits early records nothing while the guard "
|
||||
"still expects it — or worse, records success for work that did not happen."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("job", MARKED_JOBS)
|
||||
def test_the_guard_expects_EXACTLY_the_set_of_marked_keys_in_the_right_bucket(job):
|
||||
"""Set equality in BOTH directions, plus the bucket, because each failure is silent differently.
|
||||
|
||||
A key marked but not expected → the guard never notices that step being dropped: a fail-open
|
||||
that looks fully guarded. A key expected but not marked → the guard reddens on every single run,
|
||||
which is fail-closed but reads as "this guard is broken" and is how a correct guard gets deleted.
|
||||
|
||||
The BUCKET has to match the step's own `if:`. A gated step listed under `--always` reddens every
|
||||
docs-only and already-validated run — the two paths whose entire purpose is to report green in
|
||||
seconds. An always-run step listed under `--gated` stops being checked the moment either skip
|
||||
gate fires, which is a fail-open on precisely the runs where least else is happening.
|
||||
"""
|
||||
marked = _marked(job)
|
||||
keys = [k for _, k in marked]
|
||||
assert len(keys) == len(set(keys)), (
|
||||
f"job '{job}' reuses a marker key: {[k for k in keys if keys.count(k) > 1]}. Two steps "
|
||||
"sharing a key means either one satisfies the guard for both, so dropping one is invisible."
|
||||
)
|
||||
always, gated = _guard_buckets(job)
|
||||
assert sorted(always + gated) == sorted(keys), (
|
||||
f"job '{job}': the guard expects {sorted(always + gated)} but the steps mark "
|
||||
f"{sorted(keys)}. Keys marked-but-unexpected are unguarded drops; keys "
|
||||
"expected-but-unmarked redden every run."
|
||||
)
|
||||
# AN UNRECOGNISED `if:` IS REJECTED, never silently bucketed — found by both reviewers. The
|
||||
# protocol only knows two conditions: absent (always runs) and exactly the skip gate. A marked
|
||||
# step carrying a third condition (`if: github.event_name == 'push'`, or the `always() && <gate>`
|
||||
# spelling the peak-anon steps already use) would fall through to "always", the suite would go
|
||||
# green, and the guard would then demand a step the runner legitimately skipped — reddening a
|
||||
# REQUIRED context and deadlocking `main`. There is already a near-miss in this file: `Report
|
||||
# peak container memory` carries that third spelling and escapes only because it is
|
||||
# `continue-on-error: true` and therefore exempt from marking.
|
||||
for step, key in marked:
|
||||
cond = re.sub(r"\s+", "", str(step.get("if", "")))
|
||||
assert cond in ("", SKIP_GATE), (
|
||||
f"job '{job}': marked step {step.get('name')!r} has an `if:` the guard protocol does not "
|
||||
f"model ({step.get('if')!r}). Only 'absent' and the exact skip gate are understood; "
|
||||
"anything else would be bucketed as --always and would fail the job on a run where the "
|
||||
"step is legitimately skipped. Extend the protocol deliberately, or leave the step "
|
||||
"unmarked."
|
||||
)
|
||||
want = "gated" if _is_gated(step) else "always"
|
||||
got = "gated" if key in gated else "always"
|
||||
assert want == got, (
|
||||
f"job '{job}': step {step.get('name')!r} is {want} (if: {step.get('if')!r}) but the "
|
||||
f"guard lists its key {key!r} under --{got}."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("job", MARKED_JOBS)
|
||||
def test_the_guard_is_the_LAST_step_carries_no_if_and_is_not_advisory(job):
|
||||
"""Position and condition, which together are what make the guard reachable and quiet.
|
||||
|
||||
LAST, because a guard placed before a marked step reads a marker not yet written and fails on
|
||||
every run.
|
||||
|
||||
NO `if:` — a deliberate departure from the #751 guard's `if: always()`, and the thing most likely
|
||||
to be "corrected" back. That job has one real step, so `always()` costs nothing. These jobs have
|
||||
a dozen, and a genuine failure in an early one SKIPS every later step: an `always()` guard would
|
||||
then report "these steps never executed: typecheck web-test build dotnet-test" on top of every
|
||||
ordinary red build. That is the runner obeying its own gating, not a dropped step, and a guard
|
||||
that cries wolf on every red build gets deleted.
|
||||
|
||||
The default `if:` is `success()`, and the invariant that makes relying on it safe rather than
|
||||
lucky: this step is skipped only when an earlier step FAILED, and that failure already fails the
|
||||
job. So `guard skipped => job red`, and every path to a green job runs the guard. A dropped step
|
||||
is invisible precisely because it concludes `success` — which keeps the job green and therefore
|
||||
reaches here.
|
||||
|
||||
NOT `continue-on-error`, which would let it observe the failure and go green anyway — the whole
|
||||
defect, one attribute over.
|
||||
"""
|
||||
steps = _steps(job)
|
||||
guard = _guard(job)
|
||||
assert steps[-1] is guard, (
|
||||
f"the dropped-step guard is not the last step of '{job}' — it is at index "
|
||||
f"{steps.index(guard)} of {len(steps)}, so any marked step after it would be unguarded and "
|
||||
"the guard would read a marker that has not been written yet."
|
||||
)
|
||||
assert "if" not in guard, (
|
||||
f"the '{job}' guard carries `if: {guard.get('if')!r}`. It must have none: the default "
|
||||
"`success()` is what keeps it silent on ordinary red builds, and `always()` would make it "
|
||||
"announce a false 'these steps never executed' on every failing run. See the comment above "
|
||||
"the step for why this is a deliberate departure from the #751 guard."
|
||||
)
|
||||
assert guard.get("continue-on-error") is not True, (
|
||||
f"the '{job}' guard is continue-on-error, so it detects the dropped step and lets the job go "
|
||||
"green regardless — which is the defect it exists to remove."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("job", MARKED_JOBS)
|
||||
def test_the_guards_OWN_body_cannot_be_dropped_by_the_mechanism_it_guards_against(job):
|
||||
"""A guard the guarded mechanism can silently delete is worse than no guard.
|
||||
|
||||
Its absence is silent too: the job simply goes green with nothing checked, which is
|
||||
indistinguishable from a clean run. #751 states the rule; here it is stronger than there,
|
||||
because the body is a single command with no delimiter possible rather than 20 lines of prose
|
||||
that must be kept clean by hand.
|
||||
|
||||
The gate VALUES arrive through `env:`, which the runner interpolates per value — a bad payload
|
||||
there fails that value, not the body. Both are additionally held to naming a real context by
|
||||
test_every_workflow_expression_names_a_REAL_context_or_function in test_pr_changed_files.py.
|
||||
"""
|
||||
guard = _guard(job)
|
||||
assert not _OPENER.search(guard["run"]), (
|
||||
f"the '{job}' guard's own run body contains an expression delimiter, so the runner can drop "
|
||||
"the guard the same way it drops the steps the guard is watching — and that absence is "
|
||||
"silent as well."
|
||||
)
|
||||
assert guard["run"].strip().startswith("scripts/ci-step-ran.sh assert"), (
|
||||
f"the '{job}' guard is no longer a bare invocation: {guard['run']!r}. Keeping it to one "
|
||||
"command is what makes a delimiter impossible rather than merely absent."
|
||||
)
|
||||
# THE VALUES, not just the names — found by cold review. Asserting the keys alone accepts
|
||||
# `ETV_DOCS_ONLY: ${{ steps.detect.outputs.doc_only }}` (note the typo), which names a real
|
||||
# context so the repo-wide expression check passes it too. The guard would then read an EMPTY
|
||||
# value on a docs-only run, demand the gated steps that were correctly skipped, and redden a
|
||||
# REQUIRED context on every docs-only PR.
|
||||
# THE TWO MAPPINGS MUST BE PRESENT AND CORRECT — but this deliberately does NOT demand that the
|
||||
# `env:` block contain ONLY them. An earlier version compared the whole dict, which false-redded
|
||||
# on adding an unrelated variable (an `LC_ALL`, say) and on the equally-valid `${{x}}` spacing;
|
||||
# a red here blocks every merge through the combined status, so brittleness is a real cost and
|
||||
# not a free strictness win. Whitespace inside the delimiters is normalised for the same reason.
|
||||
env = {k: re.sub(r"\s+", "", str(v)) for k, v in (guard.get("env") or {}).items()}
|
||||
for name, want in (("ETV_DOCS_ONLY", "${{steps.detect.outputs.docs_only}}"),
|
||||
("ETV_REVALIDATE_SKIP", "${{steps.revalidate.outputs.skip}}")):
|
||||
assert env.get(name) == want, (
|
||||
f"the '{job}' guard's env: has {name}={guard.get('env', {}).get(name)!r}, expected the "
|
||||
f"output the gated steps' own `if:` reads ({want}). A typo here is SILENT rather than "
|
||||
"loud: it still names a real context, so the repo-wide expression check passes it, the "
|
||||
"value arrives empty, and the guard then demands steps that were legitimately skipped — "
|
||||
"reddening a REQUIRED context on every docs-only run."
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
# BEHAVIOURAL — the guard's real command line, against markers written by the steps' real lines
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mark_line(step) -> str:
|
||||
"""The step's OWN marker line, verbatim from the workflow.
|
||||
|
||||
Extracted rather than rebuilt in Python ON PURPOSE. A test that composed the command itself
|
||||
would keep passing after the workflow and the script drifted apart on the path, the quoting or
|
||||
the sub-command — and that divergence is exactly the failure that makes the guard fail on every
|
||||
run and then get deleted as broken. Running the real line proves the two agree by construction.
|
||||
"""
|
||||
line = next(ln for ln in step["run"].splitlines() if _MARK.search(ln))
|
||||
return line.strip()
|
||||
|
||||
|
||||
# THE GATE VALUES DEFAULT TO `"false"`, WHICH IS WHAT THE RUNNER ACTUALLY SENDS — and getting this
|
||||
# wrong made the whole suite blind. Found by cold review, which demonstrated it: every behavioural
|
||||
# test used to leave these UNSET, so the guard was never once driven at its production values. Change
|
||||
# the gate in `ci-step-ran.sh` from `= "true"` to `-n` — a one-token regression — and all 30 tests
|
||||
# stayed GREEN while the guard, run with the real environment, reported
|
||||
# `Skip gate fired (docs_only='false') … All 2 expected step(s) executed` and exited 0. `Build`,
|
||||
# `Test` and both migration replays would have been unguarded on every ordinary run, with the guard
|
||||
# announcing that it had proved everything.
|
||||
#
|
||||
# THE COMPLETE VALUE SET, and where each comes from — worth spelling out, because the obvious reading
|
||||
# of the evidence is wrong. Both producers document `true|false` and write exactly that
|
||||
# (`scripts/ci-detect-docs-only.sh` -> `docs_only=`, `scripts/ci-detect-already-validated.sh` ->
|
||||
# `skip=`), so an ordinary run sends `false` and a skipping run sends `true`.
|
||||
#
|
||||
# The live log of the probe this change cites (run 1910, job 8064) shows `ETV_DOCS_ONLY: false` and
|
||||
# `ETV_REVALIDATE_SKIP:` EMPTY — but do NOT read that as revalidate's normal output. `revalidate` was
|
||||
# the step the probe deliberately dropped, so it wrote no output at all. The empty string is
|
||||
# therefore not an odd third state: it is the SIGNATURE OF THE VERY FAILURE THIS GUARD EXISTS TO
|
||||
# CATCH, which is exactly why the gate must treat anything that is not `true` as "widen what is
|
||||
# required". `None` (unset) is the same case reached a different way.
|
||||
#
|
||||
# A test double is an assertion about what the real system sends, and the earlier version of this one
|
||||
# was wrong about the only field the guard branches on.
|
||||
GATE_VALUES_IN_THE_WILD = ("false", "", None)
|
||||
|
||||
|
||||
def _env(tmp_path, **extra):
|
||||
env = {
|
||||
"PATH": os.environ["PATH"],
|
||||
"GITHUB_WORKSPACE": str(REPO_ROOT),
|
||||
"RUNNER_TEMP": str(tmp_path),
|
||||
"GITHUB_JOB": "test",
|
||||
"GITHUB_RUN_ID": "424242",
|
||||
"GITHUB_RUN_ATTEMPT": "7",
|
||||
"ETV_DOCS_ONLY": "false",
|
||||
"ETV_REVALIDATE_SKIP": "false",
|
||||
}
|
||||
env.update(extra)
|
||||
return {k: v for k, v in env.items() if v is not None}
|
||||
|
||||
|
||||
def _run(script: str, env):
|
||||
return subprocess.run(["bash", "-c", script], cwd=REPO_ROOT, env=env,
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gate", GATE_VALUES_IN_THE_WILD,
|
||||
ids=["gate-false", "gate-empty", "gate-unset"])
|
||||
@pytest.mark.parametrize("job", MARKED_JOBS)
|
||||
def test_the_guard_PASSES_when_every_step_marked_itself(job, gate, tmp_path):
|
||||
"""The positive control. Without it, a guard that always failed would satisfy every case below.
|
||||
|
||||
`GITHUB_JOB` is set to the job under test, so this also covers the marker file being keyed per
|
||||
job: if it were not, the two jobs would share a file and one job's markers would answer for the
|
||||
other's dropped steps.
|
||||
"""
|
||||
marks = [_mark_line(s) for s, _ in _marked(job)]
|
||||
guard = _guard(job)["run"]
|
||||
# Parametrised over every NOT-SKIPPING spelling the runner emits — `false` on an ordinary run,
|
||||
# empty when the producing step was dropped, absent if the output is never set. All three must
|
||||
# require the gated steps; a gate that treats any of them as a skip is fail-open on that path.
|
||||
env = _env(tmp_path, GITHUB_JOB=job, ETV_DOCS_ONLY=gate, ETV_REVALIDATE_SKIP=gate)
|
||||
r = _run("\n".join(["set -e", *marks, guard]), env)
|
||||
assert r.returncode == 0, (
|
||||
f"the '{job}' guard rejected a run in which every step marked itself — the steps and the "
|
||||
f"guard disagree, so this would fail on every run.\n{r.stdout}\n{r.stderr}"
|
||||
)
|
||||
assert "All" in r.stdout and "executed" in r.stdout, r.stdout
|
||||
# The other half of the identity contract: with GITHUB_RUN_ATTEMPT set (`_env` sends 7) the line
|
||||
# must report the REAL value and say so. A mis-derivation (`${marker#*-}` rather than `##`) or an
|
||||
# inverted provenance test would otherwise ship silently, and the operator reading this line to
|
||||
# settle the promotion question would read it wrong.
|
||||
assert f"Marker identity: job={job} run=424242 attempt=7 (from the runner)" in r.stdout, (
|
||||
f"the guard misreported its marker identity: {r.stdout!r}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("job", MARKED_JOBS)
|
||||
def test_dropping_ANY_single_step_FAILS_the_guard(job, tmp_path):
|
||||
"""Every marked step, one at a time — not a sample.
|
||||
|
||||
An arbitrary sample gives false negatives here: the interesting drop is `Test` or the migration
|
||||
replay, and a test that only omitted the first step would prove the guard catches the one case
|
||||
that was never fail-open anyway. Dropping each key in turn is the only version that establishes
|
||||
the property the issue asks for.
|
||||
"""
|
||||
marked = _marked(job)
|
||||
guard = _guard(job)["run"]
|
||||
for dropped_step, dropped_key in marked:
|
||||
d = tmp_path / dropped_key
|
||||
d.mkdir()
|
||||
marks = [_mark_line(s) for s, k in marked if k != dropped_key]
|
||||
r = _run("\n".join(["set -e", *marks, guard]), _env(d, GITHUB_JOB=job))
|
||||
assert r.returncode != 0, (
|
||||
f"job '{job}': the guard went GREEN with {dropped_step.get('name')!r} "
|
||||
f"(key {dropped_key!r}) never having executed. That is a REQUIRED context reporting "
|
||||
f"success having skipped that work — the exact fail-open of ersatztv#756.\n{r.stdout}"
|
||||
)
|
||||
assert dropped_key in (r.stdout + r.stderr), (
|
||||
f"the guard failed but did not name the missing step {dropped_key!r}: {r.stdout}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("job", MARKED_JOBS)
|
||||
@pytest.mark.parametrize("gate", ["ETV_DOCS_ONLY", "ETV_REVALIDATE_SKIP"])
|
||||
def test_a_fired_skip_gate_does_not_require_the_gated_steps(gate, job, tmp_path):
|
||||
"""The docs-only and already-validated paths must still report green in seconds.
|
||||
|
||||
They are the reason these jobs are never `if:`-skipped at the JOB level (a skipped required
|
||||
context is a state this repo deliberately does not rely on — ersatztv#416/#418), so a guard that
|
||||
reddened them would make every docs-only PR unmergeable. Which is #751's user-visible symptom
|
||||
arriving from the opposite direction, and worth a test rather than a comment.
|
||||
"""
|
||||
marks = [_mark_line(s) for s, k in _marked(job) if k in _guard_buckets(job)[0]]
|
||||
guard = _guard(job)["run"]
|
||||
r = _run("\n".join(["set -e", *marks, guard]), _env(tmp_path, GITHUB_JOB=job, **{gate: "true"}))
|
||||
assert r.returncode == 0, (
|
||||
f"with {gate}=true the guard still demanded the gated steps, so every docs-only / "
|
||||
f"already-validated run of a REQUIRED job would be red.\n{r.stdout}\n{r.stderr}"
|
||||
)
|
||||
assert "Skip gate fired" in r.stdout, r.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize("job", MARKED_JOBS)
|
||||
def test_a_fired_skip_gate_STILL_requires_the_ALWAYS_steps(job, tmp_path):
|
||||
"""The negative control for the test above — otherwise `ETV_DOCS_ONLY=true` would be a blanket
|
||||
off-switch and the previous test would be passing for the wrong reason.
|
||||
|
||||
This is the case that matters most on a docs-only run: the detect steps are the only things that
|
||||
execute, so if their drop were unguarded the skip path would be entirely unchecked.
|
||||
"""
|
||||
guard = _guard(job)["run"]
|
||||
r = _run("\n".join(["set -e", guard]), _env(tmp_path, GITHUB_JOB=job, ETV_DOCS_ONLY="true"))
|
||||
assert r.returncode != 0, (
|
||||
"with ETV_DOCS_ONLY=true and NO steps marked at all, the guard passed — the skip gate is "
|
||||
"acting as a blanket off-switch rather than as a narrowing of what is expected."
|
||||
)
|
||||
assert "detect" in (r.stdout + r.stderr), r.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize("job", MARKED_JOBS)
|
||||
def test_an_EMPTY_gate_value_requires_the_gated_steps(job, tmp_path):
|
||||
"""A dropped `detect` step leaves its outputs EMPTY, not 'false'.
|
||||
|
||||
Reading empty as "skipped" would mean the one drop that disables the detect step also disables
|
||||
the guard for everything downstream — the guard switching itself off in response to the very
|
||||
failure it exists to catch. The direction has to be: anything that is not exactly `true` widens
|
||||
what is required.
|
||||
"""
|
||||
guard = _guard(job)["run"]
|
||||
r = _run("\n".join(["set -e", guard]),
|
||||
_env(tmp_path, GITHUB_JOB=job, ETV_DOCS_ONLY="", ETV_REVALIDATE_SKIP=""))
|
||||
assert r.returncode != 0
|
||||
assert _guard_buckets(job)[1][-1] in (r.stdout + r.stderr), (
|
||||
f"empty gate values were read as a skip, so the gated steps went unchecked: {r.stdout}"
|
||||
)
|
||||
|
||||
|
||||
def test_a_STALE_marker_from_another_run_cannot_satisfy_the_guard(tmp_path):
|
||||
"""A marker from another run, attempt or job must never answer for this one.
|
||||
|
||||
Do NOT restate this as "RUNNER_TEMP is /tmp, not a private per-job directory". That is a #751
|
||||
measurement taken on a job with no `container:`, and it does not transfer: these two jobs run
|
||||
inside the CI toolchain image, so their `/tmp` is the container's own. The fresh container is
|
||||
what actually rules out staleness here; the keying is defence in depth against a lane change
|
||||
nobody would think to re-check this against, and that is why it is still worth testing.
|
||||
"""
|
||||
marks = [_mark_line(s) for s, _ in _marked("test")]
|
||||
guard = _guard("test")["run"]
|
||||
# Run 1 marks everything.
|
||||
first = _env(tmp_path, GITHUB_JOB="test", GITHUB_RUN_ID="111", GITHUB_RUN_ATTEMPT="1")
|
||||
assert _run("\n".join(["set -e", *marks]), first).returncode == 0
|
||||
# Run 2 shares RUNNER_TEMP but marks nothing. It must NOT inherit run 1's markers.
|
||||
second = _env(tmp_path, GITHUB_JOB="test", GITHUB_RUN_ID="222", GITHUB_RUN_ATTEMPT="1")
|
||||
r = _run(guard, second)
|
||||
assert r.returncode != 0, (
|
||||
"a marker file left by a DIFFERENT run satisfied the guard, so a run whose steps were all "
|
||||
f"dropped would pass silently.\n{r.stdout}"
|
||||
)
|
||||
# ...and a RETRY of run 1 must not inherit run 1's either.
|
||||
retry = _env(tmp_path, GITHUB_JOB="test", GITHUB_RUN_ID="111", GITHUB_RUN_ATTEMPT="2")
|
||||
assert _run(guard, retry).returncode != 0, (
|
||||
"a re-run inherited the first attempt's markers, so a step dropped only on the retry passes"
|
||||
)
|
||||
# ...nor may the OTHER job in the same run inherit them.
|
||||
sibling = _env(tmp_path, GITHUB_JOB="migrations", GITHUB_RUN_ID="111", GITHUB_RUN_ATTEMPT="1")
|
||||
assert _run(_guard("migrations")["run"], sibling).returncode != 0, (
|
||||
"the two required jobs share one marker file, so one job's markers answer for the other's "
|
||||
"dropped steps"
|
||||
)
|
||||
|
||||
|
||||
def test_assert_with_no_expected_keys_REFUSES_instead_of_passing(tmp_path):
|
||||
"""The script's own anti-vacuity check, exercised rather than trusted.
|
||||
|
||||
`assert` with an empty expectation list would print "All 0 expected step(s) executed" and exit 0
|
||||
— a guard that proves nothing while reporting that it proved everything. That is how a guard
|
||||
ends up shipped and dead, which this repo has now done twice (#751's fence, #751's own guard).
|
||||
"""
|
||||
r = _run(f"{SCRIPT} assert", _env(tmp_path))
|
||||
assert r.returncode == 2, f"expected a usage refusal, got {r.returncode}: {r.stdout} {r.stderr}"
|
||||
assert "no expected keys" in (r.stdout + r.stderr)
|
||||
|
||||
|
||||
def test_mark_APPENDS_so_one_step_does_not_erase_its_predecessors(tmp_path):
|
||||
"""`>` instead of `>>` in the script would leave only the last step's key.
|
||||
|
||||
The guard would then redden on every run — fail-closed, but it would look like the guard is
|
||||
broken rather than like a real drop, and that is the state in which a correct guard gets removed.
|
||||
"""
|
||||
env = _env(tmp_path)
|
||||
assert _run(f"{SCRIPT} mark alpha && {SCRIPT} mark beta", env).returncode == 0
|
||||
r = _run(f"{SCRIPT} assert --always alpha beta", env)
|
||||
assert r.returncode == 0, f"the second mark erased the first: {r.stdout} {r.stderr}"
|
||||
|
||||
|
||||
def test_a_key_is_matched_WHOLE_not_as_a_substring(tmp_path):
|
||||
"""`build` must not be satisfied by `web-build`, and `test` not by `web-test`.
|
||||
|
||||
Both pairs are live key names in the `test` job, so a substring match would mean dropping the
|
||||
real `Build` or `Test` step — the two most consequential steps in the whole workflow — is
|
||||
invisible because an SPA step of a similar name ran.
|
||||
"""
|
||||
env = _env(tmp_path)
|
||||
assert _run(f"{SCRIPT} mark web-build && {SCRIPT} mark web-test", env).returncode == 0
|
||||
r = _run(f"{SCRIPT} assert --always build", env)
|
||||
assert r.returncode != 0, (
|
||||
"the key 'build' was satisfied by a marker for 'web-build' — a dropped `dotnet build` would "
|
||||
"pass unnoticed"
|
||||
)
|
||||
|
||||
|
||||
def test_a_degraded_run_IDENTITY_refuses_rather_than_sharing_a_marker_path(tmp_path):
|
||||
"""`GITHUB_RUN_ID` absent must REFUSE, not fall back to a name every run shares.
|
||||
|
||||
The first version of `marker_path` defaulted to `nojob`/`norunid`/`1`. Those are reusable, so a
|
||||
leftover marker from any earlier run on the host would satisfy the guard on a run whose step was
|
||||
dropped — a silent PASS, which is the precise failure the run-keying exists to remove,
|
||||
reintroduced by the code implementing it. Found by cold review.
|
||||
|
||||
Asserted on BOTH sub-commands: a refusal that only `assert` honoured would let `mark` write to a
|
||||
shared path and leave the two disagreeing about where the file is.
|
||||
"""
|
||||
env = _env(tmp_path)
|
||||
for var in ("GITHUB_RUN_ID", "GITHUB_JOB", "GITHUB_RUN_ATTEMPT"):
|
||||
degraded = {k: v for k, v in env.items() if k != var}
|
||||
for argv in (f"{SCRIPT} mark alpha", f"{SCRIPT} assert --always alpha"):
|
||||
r = _run(argv, degraded)
|
||||
assert r.returncode != 0, (
|
||||
f"with {var} unset, `{argv.split()[-2]}` continued and used a fallback path that "
|
||||
f"other runs also use — a stale marker there passes the guard on a dropped run.\n"
|
||||
f"{r.stdout}{r.stderr}"
|
||||
)
|
||||
assert "cannot identify this run" in (r.stdout + r.stderr), (
|
||||
f"refused, but without naming the cause: {r.stdout!r} {r.stderr!r}")
|
||||
assert not list(tmp_path.iterdir()), (
|
||||
"a degraded-identity `mark` still created a marker file somewhere under RUNNER_TEMP")
|
||||
|
||||
|
||||
def test_the_marker_identity_is_REPORTED_on_stdout_every_run(tmp_path):
|
||||
"""The line that settled `GITHUB_RUN_ATTEMPT`, kept as standing evidence.
|
||||
|
||||
Worth recording HOW that was settled, because the first two attempts were both bad. Grepping a
|
||||
job log for the variable NAME proves nothing (logs do not dump the environment). Inferring it
|
||||
from the ABSENCE of a "not set" warning proves nothing either, because that warning goes to
|
||||
stderr and whether step stderr reaches a job log here was itself never established — the control
|
||||
offered for that was an `::error::` this script writes to STDOUT. So the script was made to
|
||||
REPORT its resolved identity on stdout, where capture is not in question, and the answer was read
|
||||
off ersatztv#756's own PR run: `Marker identity: job=test run=1916 attempt=1 (from the runner)`,
|
||||
and the same for `migrations`. That is what promoted the variable from warn-and-default to
|
||||
required.
|
||||
|
||||
Asserted because cold review demonstrated three mutations of this reporting — deleting the echo,
|
||||
mis-deriving the attempt, inverting the provenance — all surviving a 50-green suite. It is a
|
||||
documented contract (the record's `mechanics:`), and a future reader is told to trust it.
|
||||
"""
|
||||
marks = [_mark_line(s) for s, _ in _marked("test")]
|
||||
r = _run("\n".join(["set -e", *marks, _guard("test")["run"]]),
|
||||
_env(tmp_path, GITHUB_RUN_ID="1916", GITHUB_RUN_ATTEMPT="4"))
|
||||
assert r.returncode == 0, r.stdout + r.stderr
|
||||
assert "Marker identity: job=test run=1916 attempt=4 (from the runner)" in r.stdout, (
|
||||
"the guard did not report the identity its marker path was actually keyed on, so a reader "
|
||||
f"cannot audit the keying from a run log: {r.stdout!r}")
|
||||
|
||||
|
||||
def test_a_skip_gate_that_empties_the_expected_set_REFUSES(tmp_path):
|
||||
"""The anti-vacuity check has to run AFTER the gate, not only on argv. Cold review reproduced
|
||||
this exactly:
|
||||
|
||||
ETV_DOCS_ONLY=true … assert --always --gated foo
|
||||
-> "All 0 expected step(s) executed", exit 0
|
||||
|
||||
The argv check cannot see it, because the set is emptied by the gate rather than by the caller.
|
||||
Unreachable with today's argv, but it contradicted the comment directly above it — and "reports
|
||||
that it proved everything while proving nothing" is the failure this whole file exists to remove.
|
||||
"""
|
||||
r = _run(f"{SCRIPT} assert --always --gated foo", _env(tmp_path, ETV_DOCS_ONLY="true"))
|
||||
assert r.returncode != 0, (
|
||||
f"the guard passed with an empty post-gate expectation set: {r.stdout!r}")
|
||||
assert "no expected keys" in (r.stdout + r.stderr).lower() or "NO expected keys" in r.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("revalidate", ["true", "false", "", None],
|
||||
ids=lambda v: f"reval-{v if v is not None else 'unset'}")
|
||||
@pytest.mark.parametrize("docs_only", ["true", "false", "", None],
|
||||
ids=lambda v: f"docs-{v if v is not None else 'unset'}")
|
||||
def test_the_skip_gate_over_the_WHOLE_value_matrix(docs_only, revalidate, tmp_path):
|
||||
"""Every combination of the two gate values, not just the diagonal — cold review's last finding.
|
||||
|
||||
Round 3 fixed the suite's blindness to the production value `false`, but still only exercised
|
||||
matched pairs and single-`true` cases. `(true, true)` is REACHABLE — a docs-only PR merged to
|
||||
`main` whose tree was already validated sets both — and an exclusive-or regression would pass
|
||||
every other test here while demanding all the gated markers on a run that legitimately skipped
|
||||
those steps. That reddens BOTH required contexts, which is the false-red direction: it deadlocks
|
||||
every merge rather than letting one through.
|
||||
|
||||
The property asserted is the whole contract in one line: with only the `--always` keys marked,
|
||||
the guard passes exactly when the gate says the gated steps were skipped — `true` in EITHER
|
||||
variable, and nothing else. Sixteen cases, so no combination is a special case anyone has to
|
||||
remember.
|
||||
|
||||
On `unset`: the workflow's `env:` block always defines both, emitting EMPTY for an output the
|
||||
producing step never wrote, so unset is not reachable through the workflow. It is covered because
|
||||
the script is also runnable by hand, and because "not exactly true" is the property that must
|
||||
hold for every spelling rather than for an enumerated list.
|
||||
"""
|
||||
job = "test"
|
||||
always, gated = _guard_buckets(job)
|
||||
marks = [_mark_line(s) for s, k in _marked(job) if k in always]
|
||||
r = _run("\n".join(["set -e", *marks, _guard(job)["run"]]),
|
||||
_env(tmp_path, ETV_DOCS_ONLY=docs_only, ETV_REVALIDATE_SKIP=revalidate))
|
||||
should_skip = docs_only == "true" or revalidate == "true"
|
||||
assert (r.returncode == 0) is should_skip, (
|
||||
f"with docs_only={docs_only!r} and revalidate={revalidate!r} the guard "
|
||||
f"{'passed' if r.returncode == 0 else 'failed'}, expected it to "
|
||||
f"{'skip the gated keys' if should_skip else 'require them'}. The gate must treat a value as "
|
||||
"a skip if and only if it is exactly `true` in EITHER variable.\n" + r.stdout + r.stderr)
|
||||
@@ -0,0 +1,243 @@
|
||||
"""The `scan` job — the delimiter ban made fail-CLOSED on the release path (ersatztv#767).
|
||||
|
||||
WHAT THIS IS PROTECTING. #756 brought `build` into the delimiter ban, because a dropped
|
||||
`Smoke + IPTV E2E` publishes a release candidate that was never booted and reports the job green.
|
||||
But the ban was enforced ONLY by `test_the_delimiter_banned_jobs_have_NO_expression_delimiter_in_any_run_body`
|
||||
in `script-tests` — `on: pull_request`, not a required context. Nothing re-checked it on a `v*` tag
|
||||
push, which is exactly when the candidate is published.
|
||||
|
||||
WHY A JOB AND NOT A STEP IN `build`, and why this file is structural. The first cut of #767 put a
|
||||
bespoke stdlib scanner in `build` itself. Two independent reviews killed it on two counts, and both
|
||||
are worth keeping written down because both are easy to re-invent:
|
||||
|
||||
* A guard step inside `build` cannot protect `build`. If the runner drops it, the job carries on
|
||||
and publishes — fail-OPEN. The defence offered was "the guard's own body has no opener, so it
|
||||
cannot be dropped", but the only thing enforcing THAT was the same PR-only test being
|
||||
backstopped. Circular. As a `needs:` of `build`, a red here means `build` never runs at all.
|
||||
* The bespoke scanner hand-parsed YAML (to avoid provisioning PyYAML on `build`'s bare runner) and
|
||||
had ~10 false NEGATIVES within one review round — flow mappings, a quoted `"run":` key, aliases,
|
||||
multiline quoted scalars. It was strictly WEAKER than the check it backstopped, in the only
|
||||
direction that matters. The fix was to delete it and run the real PyYAML-based test, which needs
|
||||
no second definition of "what is a `run:` body" and so has no drift surface.
|
||||
|
||||
So the detection logic is not retested here — it lives in `test_ci_dropped_step_guard.py` and this
|
||||
job runs that file. What this file holds is the WIRING, which is what makes the ban fail-closed:
|
||||
the job exists, `build` depends on it, nothing can skip it, its own steps cannot be silently
|
||||
dropped, and it actually invokes the ban test.
|
||||
|
||||
WHAT THIS DOES NOT CLAIM. That no step can ever fail to run for a reason other than the
|
||||
interpolation drop. This job's own steps carry #756 markers and a trailing assert, so the regress
|
||||
terminates where the sibling guards' does — to fail open you must now drop the pytest step AND the
|
||||
assert step, not either one. The end-to-end behaviour (a poisoned `Smoke` body reddens `scan` and
|
||||
`build` never runs) is a LIVE measurement recorded on the issue, not something a static test here
|
||||
can establish.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "docker-build.yml"
|
||||
SCRIPT = REPO_ROOT / "scripts" / "ci-step-ran.sh"
|
||||
|
||||
_DOC = yaml.safe_load(WORKFLOW.read_text())
|
||||
_OPENER = re.compile(r"\$\{\{")
|
||||
_MARK = re.compile(r'ci-step-ran\.sh"?\s+mark\s+(\S+)')
|
||||
|
||||
JOB = "scan"
|
||||
BAN_TEST_FILE = "scripts/tests/test_ci_dropped_step_guard.py"
|
||||
|
||||
|
||||
def _job():
|
||||
assert JOB in _DOC["jobs"], f"the `{JOB}` job is gone — the release path is unguarded again"
|
||||
return _DOC["jobs"][JOB]
|
||||
|
||||
|
||||
def _steps():
|
||||
return _job()["steps"]
|
||||
|
||||
|
||||
def _run_steps():
|
||||
return [s for s in _steps() if s.get("run")]
|
||||
|
||||
|
||||
def _guard():
|
||||
"""The trailing assert step, located by CONTENT — never by index, so that
|
||||
`test_the_guard_is_the_LAST_step` is not true by construction."""
|
||||
hits = [s for s in _run_steps() if "ci-step-ran.sh assert" in s["run"]]
|
||||
assert len(hits) == 1, f"expected exactly 1 assert step in `{JOB}`, found {len(hits)}"
|
||||
return hits[0]
|
||||
|
||||
|
||||
def _marked():
|
||||
out = []
|
||||
for s in _run_steps():
|
||||
m = _MARK.search(s["run"])
|
||||
if m:
|
||||
out.append((s, m.group(1)))
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
# WIRING — the properties that make the ban fail-closed
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_DEPENDS_on_the_scan_job():
|
||||
"""This single edge is the whole fail-closed property.
|
||||
|
||||
Without it the scan is advisory: it could go red while `build` publishes anyway.
|
||||
"""
|
||||
needs = _DOC["jobs"]["build"]["needs"]
|
||||
needs = [needs] if isinstance(needs, str) else needs
|
||||
assert JOB in needs, f"`build` no longer needs `{JOB}` — a red scan would not stop a release"
|
||||
|
||||
|
||||
def test_the_scan_job_has_NO_job_level_if():
|
||||
"""Two failure modes at once, in opposite directions.
|
||||
|
||||
An `if:` that excludes the tag push would leave the release path unguarded — the exact hole
|
||||
#767 closed. An `if:` that skipped it for any other reason would SKIP `build` too (a skipped
|
||||
dependency skips its dependents), breaking every release. Neither is wanted: it always runs.
|
||||
"""
|
||||
job = _job()
|
||||
assert "if" not in job, f"`{JOB}` must carry no job-level `if:`, found {job.get('if')!r}"
|
||||
|
||||
|
||||
def test_the_scan_job_actually_invokes_the_ban_test():
|
||||
"""Otherwise the job is an expensive no-op that reports green.
|
||||
|
||||
Asserted against the file path the ban test really lives in, so renaming that file without
|
||||
updating the workflow is a red here rather than a silently unguarded release path.
|
||||
"""
|
||||
assert (REPO_ROOT / BAN_TEST_FILE).is_file()
|
||||
assert any(BAN_TEST_FILE in s["run"] for s in _run_steps()), (
|
||||
f"no step in `{JOB}` runs {BAN_TEST_FILE}"
|
||||
)
|
||||
|
||||
|
||||
def test_no_step_in_the_scan_job_is_advisory():
|
||||
"""`continue-on-error: true` would make the whole gate a no-op while every other test here
|
||||
stayed green — it is the cheapest way to accidentally disarm this."""
|
||||
offenders = [s.get("name") for s in _steps() if s.get("continue-on-error")]
|
||||
assert not offenders, f"advisory step(s) in `{JOB}`: {offenders}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"step_name",
|
||||
[s.get("name", "?") for s in yaml.safe_load(WORKFLOW.read_text())["jobs"][JOB]["steps"]
|
||||
if s.get("run")],
|
||||
)
|
||||
def test_every_run_body_in_the_scan_job_is_delimiter_free(step_name):
|
||||
"""The guard must not be vulnerable to the defect it guards against.
|
||||
|
||||
Not a proof that it always runs — a construction argument about ONE mechanism, the same axiom
|
||||
the sibling guards rest on. It is asserted per step so a failure names which step regressed.
|
||||
"""
|
||||
step = next(s for s in _run_steps() if s.get("name", "?") == step_name)
|
||||
assert not _OPENER.search(step["run"]), (
|
||||
f"step {step_name!r} of `{JOB}` contains an expression delimiter; the runner would rewrite "
|
||||
"the whole body and DROP the step while reporting success (ersatztv#751). Pass values "
|
||||
"through `env:`, which is interpolated per value."
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
# THE JOB'S OWN DROPPED-STEP GUARD
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_every_consequential_step_marks_itself():
|
||||
"""Every `run:` step except the guard records that it executed."""
|
||||
marked = {s.get("name") for s, _ in _marked()}
|
||||
expected = {s.get("name") for s in _run_steps() if s is not _guard()}
|
||||
assert marked == expected, f"unmarked step(s) in `{JOB}`: {expected - marked}"
|
||||
|
||||
|
||||
def test_the_guard_expectations_match_the_markers_exactly():
|
||||
"""The set the guard waits for IS the set the steps write — derived from the workflow, not
|
||||
restated here, so adding a step without a marker is a red."""
|
||||
argv = _guard()["run"].split()
|
||||
assert "--always" in argv, argv
|
||||
always = argv[argv.index("--always") + 1:]
|
||||
assert "--gated" not in argv, "every step in this job is unconditional; there is nothing to gate"
|
||||
assert sorted(always) == sorted(k for _, k in _marked())
|
||||
|
||||
|
||||
def test_the_guard_is_the_LAST_step():
|
||||
assert _steps()[-1] is _guard(), "the assert must run after the steps it checks"
|
||||
|
||||
|
||||
def test_the_guard_has_no_if():
|
||||
"""Same reasoning as the sibling guards: the default `success()` is wanted, because a genuine
|
||||
early failure legitimately skips later steps and already fails the job."""
|
||||
assert "if" not in _guard()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
# BEHAVIOURAL — the guard's REAL command line, against the steps' REAL marker lines
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mark_line(step) -> str:
|
||||
"""The step's own marker line, verbatim from the workflow — never rebuilt in Python, so a
|
||||
drift between the workflow and the script cannot hide behind a test that composed its own."""
|
||||
return next(ln for ln in step["run"].splitlines() if _MARK.search(ln)).strip()
|
||||
|
||||
|
||||
def _env(tmp_path, **extra):
|
||||
env = {
|
||||
"PATH": os.environ["PATH"],
|
||||
"GITHUB_WORKSPACE": str(REPO_ROOT),
|
||||
"RUNNER_TEMP": str(tmp_path),
|
||||
"GITHUB_JOB": JOB,
|
||||
"GITHUB_RUN_ID": "424242",
|
||||
"GITHUB_RUN_ATTEMPT": "7",
|
||||
}
|
||||
env.update(extra)
|
||||
return {k: v for k, v in env.items() if v is not None}
|
||||
|
||||
|
||||
def _run(script: str, env):
|
||||
return subprocess.run(["bash", "-c", script], cwd=REPO_ROOT, env=env,
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_the_guard_PASSES_when_every_step_ran(tmp_path):
|
||||
env = _env(tmp_path)
|
||||
for step, _ in _marked():
|
||||
assert _run(_mark_line(step), env).returncode == 0
|
||||
res = _run(_guard()["run"], env)
|
||||
assert res.returncode == 0, res.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dropped", [k for _, k in _marked()])
|
||||
def test_the_guard_FAILS_when_a_step_was_dropped(tmp_path, dropped):
|
||||
"""The positive control. Drop each key in turn — the guard must go red and NAME it.
|
||||
|
||||
A guard only ever exercised on the happy path is indistinguishable from one that passes
|
||||
unconditionally, which is the failure this whole mechanism exists to remove.
|
||||
"""
|
||||
env = _env(tmp_path)
|
||||
for step, key in _marked():
|
||||
if key != dropped:
|
||||
assert _run(_mark_line(step), env).returncode == 0
|
||||
res = _run(_guard()["run"], env)
|
||||
assert res.returncode != 0, f"guard passed despite '{dropped}' never running: {res.stdout}"
|
||||
# BOTH streams: the script's `::error::` lands on stdout here while other diagnostics go to
|
||||
# stderr, and a test that picked the wrong one would assert on an empty string and pass for the
|
||||
# wrong reason on any message change.
|
||||
assert dropped in (res.stdout + res.stderr), (res.stdout, res.stderr)
|
||||
|
||||
|
||||
def test_the_guard_REFUSES_to_pass_with_no_expectations(tmp_path):
|
||||
"""`assert` with an empty expectation set would report success having checked nothing."""
|
||||
res = _run(f"{SCRIPT} assert --always", _env(tmp_path))
|
||||
assert res.returncode != 0
|
||||
@@ -791,9 +791,13 @@ def test_no_OTHER_workflow_writes_the_review_verdict_status():
|
||||
The trigger test above hardens ONE file, and by construction cannot see the more general
|
||||
problem: the gate is forgeable by *any* head-resolved workflow holding credentials that can POST
|
||||
a commit status, not only by the gate's own definition. `docker-build.yml` is exactly that — it
|
||||
triggers on `pull_request` (correctly; it builds the PR's code) and carries `ETV_STATUS_AUTH`,
|
||||
which a probe confirmed can WRITE statuses, not merely read them. That residual is tracked in
|
||||
ersatztv#697 and is NOT closed by this test.
|
||||
triggers on `pull_request` (correctly; it builds the PR's code) and carries `ETV_STATUS_AUTH`.
|
||||
A probe once confirmed those credentials could WRITE statuses, not merely read them
|
||||
(ersatztv#697); a follow-up probe confirmed `REGISTRY_PASSWORD` is now scoped off
|
||||
`write:repository` and the same POST returns 403 (see `ci.actions-credential-scoping`). That
|
||||
closes the registry-credential route, not the general problem — `GITEA_TOKEN`, `RENOVATE_TOKEN`,
|
||||
and a collaborator's own token can all still POST a status, and none of that is closed by this
|
||||
test.
|
||||
|
||||
What this test does close is the cheap regression: a second workflow quietly starting to write
|
||||
the context. It is a guard against drift, not a security boundary — a workflow can still write
|
||||
@@ -907,25 +911,38 @@ if "-X" in args and args[args.index("-X") + 1] == "POST":
|
||||
# edit to either pattern cannot silently reroute one endpoint into the other's handler.
|
||||
#
|
||||
# Real shapes, measured on this instance and deliberately mirrored, because the job's guards are
|
||||
# type-sensitive: this endpoint returns a BARE ARRAY of events, each with a `type`; a retarget is
|
||||
# type-sensitive: a NON-EMPTY page is a bare array of events, each with a `type`, and a retarget is
|
||||
# `change_target_branch` (confirmed on PR #703, the route-1 reproduction, which carries exactly two;
|
||||
# and on PR #717, never retargeted, which carries none).
|
||||
# and on PR #717, never retargeted, which carries none). A page PAST THE END is the JSON value `null`
|
||||
# — NOT an empty array (measured again at 1.27.1 on PR #752, ersatztv#751). The earlier version of
|
||||
# this comment claimed measured fidelity while the terminator below printed `[]`; that discrepancy is
|
||||
# why the fence's type gate was never exercised and shipped rejecting every real timeline.
|
||||
if "/timeline" in url:
|
||||
mode = os.environ.get("STUB_TIMELINE_MODE", "none")
|
||||
page = 1
|
||||
for part in url.split("?", 1)[-1].split("&"):
|
||||
if part.startswith("page="):
|
||||
page = int(part.split("=", 1)[1])
|
||||
if mode == "empty-first-page":
|
||||
# A terminator on PAGE 1 — no real page ever read (ersatztv#751). The job must NOT certify a
|
||||
# zero retarget count from this, so the exemption is withheld.
|
||||
print(os.environ.get("STUB_TIMELINE_TERMINATOR", "null"))
|
||||
sys.exit(0)
|
||||
if mode == "unreadable":
|
||||
print("<html>502 Bad Gateway</html>")
|
||||
sys.exit(0)
|
||||
if mode == "transport-error":
|
||||
sys.exit(22)
|
||||
# Page 2+ is always the validated EMPTY page that terminates the walk. Without a real terminator
|
||||
# the job would page to its cap and refuse to trust the count, which is a different branch from
|
||||
# the one most of these tests mean to exercise.
|
||||
# Page 2+ terminates the walk. THE DEFAULT IS `null`, NOT `[]`, because that is what this
|
||||
# endpoint really returns past the end — measured at Gitea 1.27.1 (ersatztv#751). This stub
|
||||
# printed `[]` for two rounds of #706 work while the comment above claimed it mirrored measured
|
||||
# reality, so every fence test was green against a shape the server never produces, and the
|
||||
# fence's `array`-only type gate — which reads `null` as unreadable — was never exercised. On the
|
||||
# real instance that made `rt_ok` false for EVERY pr, so the fence withheld every exemption.
|
||||
# Parameterised rather than simply corrected: `/issues/{n}/comments` really does return `[]` when
|
||||
# empty, so both shapes are live on this server and the job must accept either.
|
||||
if page > 1:
|
||||
print("[]")
|
||||
print(os.environ.get("STUB_TIMELINE_TERMINATOR", "null"))
|
||||
sys.exit(0)
|
||||
n_before, n_after = 0, 0
|
||||
if mode.startswith("stable:"):
|
||||
@@ -952,6 +969,47 @@ if "/timeline" in url:
|
||||
# endpoint no longer shows.
|
||||
if "/statuses/" in url:
|
||||
mode = os.environ.get("STUB_HISTORY_MODE", "none")
|
||||
|
||||
# PAGE, honoured. THE REASON IS THE TERMINATOR, NOT THE COUNTERS — and that distinction was itself
|
||||
# a finding (#751 round 5). An earlier version of this comment said the guard sits ahead of the
|
||||
# read-counting modes "so the page-2 probe cannot shift 'raced row appears on read N'", by analogy
|
||||
# with the combined endpoint. Measured: moving this guard AFTER the counter modes reddens NOTHING,
|
||||
# because no history mode that counts reads ever issues a page-2 request — `raced=1` on page 1
|
||||
# short-circuits the probe. So that half of the rationale read as a checked reason and was not,
|
||||
# which is the precise class this branch exists to retire.
|
||||
#
|
||||
# What IS load-bearing: page 2 must terminate for every mode that describes page 1 only, or the job
|
||||
# re-reads page 1's rows as a second page and concludes the list is longer than it is. Dropping
|
||||
# just the `print("[]")` below reddens `test_a_PRE_EXISTING_human_row_does_NOT_trigger_a_repair`.
|
||||
# (The ordering guard on the COMBINED endpoint is a different story and genuinely is counter-
|
||||
# related — moving that one reddens three mid-run-race tests.)
|
||||
#
|
||||
# PAST THE END THIS ENDPOINT RETURNS `[]`, NOT `null` — measured at 1.27.1:
|
||||
# `/statuses/{sha}?limit=100&page=99` is the two bytes `[]`, while `/commits/{sha}/status` past the
|
||||
# end returns `{"statuses": null}`. A THIRD distinct empty shape on one server; the job tolerates
|
||||
# both here precisely because guessing per endpoint has been wrong twice.
|
||||
hist_page = 1
|
||||
for part in url.split("?", 1)[-1].split("&"):
|
||||
if part.startswith("page="):
|
||||
try:
|
||||
hist_page = int(part.split("=", 1)[1])
|
||||
except ValueError:
|
||||
hist_page = 1
|
||||
if hist_page > 1 and mode == "second-page-garbage":
|
||||
print("<html>502 Bad Gateway</html>")
|
||||
sys.exit(0)
|
||||
if hist_page > 1 and mode == "second-page-error":
|
||||
sys.exit(22)
|
||||
if hist_page > 1 and mode not in ("second-page", "second-page-garbage", "second-page-error"):
|
||||
print("[]")
|
||||
sys.exit(0)
|
||||
if mode == "second-page":
|
||||
# The history runs past page 1, so "no raced row on page 1" does not establish "no race". The
|
||||
# rows are deliberately ORDINARY (no verdict, no sentinel): the point is that unread rows EXIST,
|
||||
# not that a verdict was found, so the repair must fire on uncertainty alone.
|
||||
print(json.dumps([{"id": 7000 + i, "context": "ci/other", "status": "success",
|
||||
"creator": None, "description": "unrelated"} for i in range(3)]))
|
||||
sys.exit(0)
|
||||
rows = []
|
||||
if mode.startswith("human-after-post"):
|
||||
# The raced verdict: absent when the high-water mark is taken, present afterwards. Its id is
|
||||
@@ -989,6 +1047,47 @@ if "/status" in url:
|
||||
# never-overwrite short-circuit unreachable: neither could be made to fire, so mutations
|
||||
# deleting them survived the whole suite.
|
||||
mode = os.environ.get("STUB_STATUS_MODE", "none")
|
||||
|
||||
# PAGE, honoured — the job now reads page 2 to justify "no verdict exists" (ersatztv#751). The
|
||||
# real endpoint pages properly: measured at 1.27.1 on a 6-context head, `?limit=3&page=2` returns
|
||||
# 3 more rows and `page=9` returns the `statuses: null` terminator. Every mode below describes
|
||||
# page 1 only, so page 2+ must terminate, or the job would read its own page-1 rows again and
|
||||
# conclude the list is longer than it is. `twopage` is the one mode with a real second page.
|
||||
status_page = 1
|
||||
for part in url.split("?", 1)[-1].split("&"):
|
||||
if part.startswith("page="):
|
||||
try:
|
||||
status_page = int(part.split("=", 1)[1])
|
||||
except ValueError:
|
||||
status_page = 1
|
||||
|
||||
# THE EMPTY SHAPE IS `statuses: null`, NOT `[]` (ersatztv#751). Measured on this instance: a head
|
||||
# with no statuses returns `{"state":"pending","total_count":0,"statuses":null}` — PR #739's head
|
||||
# 5fa672e2. This stub printed `{"statuses": []}` at all three no-verdict sites, which is a shape
|
||||
# the server does not produce for that case, so the job's `.statuses | type == "array"` gate was
|
||||
# never exercised against reality and its `exit 1` branch — posting nothing at all — was
|
||||
# unreachable in the suite. Same class as the timeline terminator, one function over.
|
||||
# Parameterised, not merely corrected: a head that HAS statuses really does return an array, so
|
||||
# both shapes are live and the job must read either.
|
||||
def empty_statuses():
|
||||
if os.environ.get("STUB_STATUS_EMPTY_SHAPE", "null") == "array":
|
||||
return json.dumps({"state": "pending", "total_count": 0, "statuses": []})
|
||||
return json.dumps({"state": "pending", "total_count": 0, "statuses": None})
|
||||
|
||||
# BEFORE ANY READ-COUNTING MODE. The `appears-on-read:N` modes count how many times the job has
|
||||
# LOOKED at the combined status, and the page-2 completeness probe is part of the same look, not a
|
||||
# further one — letting it increment those counters shifted "the verdict appears on read N" by one
|
||||
# and broke three mid-run-race tests. Page 2 also has to terminate here for every mode that
|
||||
# describes page 1 only, or the job would re-read page 1's rows as a second page and conclude the
|
||||
# list is longer than it is.
|
||||
if status_page > 1 and mode == "page2-garbage":
|
||||
print("<html>502 Bad Gateway</html>")
|
||||
sys.exit(0)
|
||||
if status_page > 1 and mode == "page2-error":
|
||||
sys.exit(22)
|
||||
if status_page > 1 and mode != "twopage":
|
||||
print(empty_statuses())
|
||||
sys.exit(0)
|
||||
if mode.startswith("appears-on-read:"):
|
||||
# A human verdict that does NOT exist at the first read and DOES exist at the re-read made
|
||||
# immediately before the POST (ersatztv#706). Models a reviewer posting BLOCKED while the job
|
||||
@@ -998,7 +1097,7 @@ if "/status" in url:
|
||||
n = int(ctr.read_text()) if ctr.exists() else 0
|
||||
ctr.write_text(str(n + 1))
|
||||
if n + 1 < nth:
|
||||
print(json.dumps({"statuses": []}))
|
||||
print(empty_statuses())
|
||||
sys.exit(0)
|
||||
print(json.dumps({"statuses": [
|
||||
{"context": "review-verdict/h10", "status": "failure",
|
||||
@@ -1013,11 +1112,36 @@ if "/status" in url:
|
||||
n = int(ctr.read_text()) if ctr.exists() else 0
|
||||
ctr.write_text(str(n + 1))
|
||||
if n + 1 < nth:
|
||||
print(json.dumps({"statuses": []})); sys.exit(0)
|
||||
print(empty_statuses()); sys.exit(0)
|
||||
print(json.dumps({"statuses": [
|
||||
{"context": "review-verdict/h10", "status": "pending", "creator": None,
|
||||
"description": "Human verdict raced this exemption write — re-post the verdict"}]}))
|
||||
sys.exit(0)
|
||||
if mode == "twopage":
|
||||
# Page 1 AND page 2 both carry decoy contexts and NO `review-verdict/h10`: the list is longer
|
||||
# than one page, so "no verdict exists" is not established and the job must refuse. Models a
|
||||
# head with more contexts than the server-wide page cap (measured 50 here) — which is exactly
|
||||
# why the guard cannot be written as a comparison against a hardcoded limit.
|
||||
print(json.dumps({"state": "pending", "total_count": 3,
|
||||
"statuses": [{"context": f"ci/p{status_page}x{i}", "status": "pending"}
|
||||
for i in range(3)]}))
|
||||
sys.exit(0)
|
||||
if mode.startswith("rows:"):
|
||||
# N decoy contexts and NO `review-verdict/h10`, to exercise the truncation guard. `total_count`
|
||||
# deliberately MIRRORS the page length, because that is what the real endpoint does — it is the
|
||||
# count for the page returned, not for the commit (measured at 1.27.1: `?limit=1` on a 6-context
|
||||
# head returns len=1, total_count=1). A stub that reported the true total would make a
|
||||
# length-vs-total guard look like it worked, which is the trap this models.
|
||||
n = int(mode.split(":", 1)[1])
|
||||
print(json.dumps({"state": "pending", "total_count": n,
|
||||
"statuses": [{"context": f"ci/decoy{i}", "status": "pending"}
|
||||
for i in range(n)]}))
|
||||
sys.exit(0)
|
||||
if mode == "total-count-string":
|
||||
# A schema-corrupted `total_count` as a STRING alongside a null `.statuses`. `jq -r` renders 0
|
||||
# and "0" identically, so a text compare would accept this.
|
||||
print(json.dumps({"state": "pending", "total_count": "0", "statuses": None}))
|
||||
sys.exit(0)
|
||||
if mode == "transport-error":
|
||||
# Real `gh()` is `curl -sf`: an HTTP error exits 22 with EMPTY stdout.
|
||||
sys.exit(22)
|
||||
@@ -1044,7 +1168,7 @@ if "/status" in url:
|
||||
"creator": ({"login": creator} if creator else None), "description": desc},
|
||||
{"context": "Functional E2E", "status": "pending"}]}))
|
||||
sys.exit(0)
|
||||
print(json.dumps({"statuses": []}))
|
||||
print(empty_statuses())
|
||||
sys.exit(0)
|
||||
|
||||
print("{}")
|
||||
@@ -1055,7 +1179,9 @@ def _run_classify(tmp_path, enum_stub: str | None, author: str = "timothy",
|
||||
status_mode: str = "none", jq16: bool = False,
|
||||
status_creator: str | None = "timothy",
|
||||
status_desc: str = "Review-verdict: MERGEABLE @ a9e3e23 (base: main)",
|
||||
timeline_mode: str = "none", history_mode: str = "none"):
|
||||
timeline_mode: str = "none", history_mode: str = "none",
|
||||
timeline_terminator: str = "null",
|
||||
status_empty_shape: str = "null"):
|
||||
"""Execute the workflow's classify `run:` block with a stubbed enumeration script.
|
||||
|
||||
Returns the status payload the job POSTed, or None if it posted nothing.
|
||||
@@ -1081,6 +1207,8 @@ def _run_classify(tmp_path, enum_stub: str | None, author: str = "timothy",
|
||||
env["STUB_STATUS_CREATOR"] = status_creator or ""
|
||||
env["STUB_STATUS_DESC"] = status_desc
|
||||
env["STUB_TIMELINE_MODE"] = timeline_mode
|
||||
env["STUB_TIMELINE_TERMINATOR"] = timeline_terminator
|
||||
env["STUB_STATUS_EMPTY_SHAPE"] = status_empty_shape
|
||||
env["STUB_HISTORY_MODE"] = history_mode
|
||||
env.update({
|
||||
"GITEA_TOKEN": "stub",
|
||||
@@ -1703,8 +1831,10 @@ def test_a_LEGACY_verdict_with_no_recorded_base_is_still_honoured(tmp_path):
|
||||
|
||||
|
||||
def test_an_APPENDED_base_cannot_override_the_real_one(tmp_path):
|
||||
"""The description is attacker-influencable by anyone who can POST a status (#697), so the parse
|
||||
must not be trickable into reading a second, appended base.
|
||||
"""The description is attacker-influencable by anyone who can POST a status — the registry
|
||||
credential's own route closed with #697, but `GITEA_TOKEN`, `RENOVATE_TOKEN`, and a
|
||||
collaborator's own token still can — so the parse must not be trickable into reading a second,
|
||||
appended base.
|
||||
|
||||
This defeated the greedy `##` parse. The implementation no longer parses at all — it requires the
|
||||
description to END with the exact literal marker AND to contain exactly one marker — so a second
|
||||
@@ -2111,3 +2241,612 @@ def test_a_SENTINEL_landing_above_the_mark_also_triggers_the_repair(tmp_path):
|
||||
assert seq[1]["state"] == "pending"
|
||||
assert seq[1]["description"] == "Human verdict raced this exemption write — re-post the verdict", (
|
||||
f"repaired, but not to the sentinel, so the fixed point is broken: {seq[1]}")
|
||||
|
||||
|
||||
# --- The expression-delimiter class (ersatztv#751) ----------------------------------------------
|
||||
#
|
||||
# WHAT HAPPENED, because the shape of it is what these tests are written against. A `run:` body is
|
||||
# not shell yet when the runner reads it. The runner scans the whole scalar for the expression
|
||||
# opener, and a single occurrence makes it rewrite the ENTIRE body into one `format(...)` call so
|
||||
# the evaluated result can be spliced back in. That rewrite is all-or-nothing, so a payload that
|
||||
# does not parse takes the whole step with it — and the runner then DROPS THE STEP AND CONCLUDES THE
|
||||
# JOB `success`.
|
||||
#
|
||||
# The occurrence that did it was in a SHELL COMMENT: the #706 note explaining why a concurrency
|
||||
# group does not work quoted a `concurrency:` snippet containing a PR-number expression as an
|
||||
# illustration. `pr number` is not an expression. The step stopped running on 2026-08-03 and nothing
|
||||
# went red until 2026-08-06.
|
||||
#
|
||||
# WHY EVERY EXISTING GUARD IN THIS FILE WAS BLIND TO IT, which is the part worth keeping: they all
|
||||
# read `_code_lines()`, which strips comment lines. That choice is correct for what it was for — its
|
||||
# own docstring explains that prose legitimately discusses `pulls/N/files`, and a raw scan would
|
||||
# redden the repo over a piece of writing. But it encodes an assumption this bug falsifies: that a
|
||||
# comment in a workflow cannot change behaviour. Inside a `run:` scalar it can. So the test below
|
||||
# reads the RAW text, deliberately, and is the one test here that must never adopt `_code_lines`.
|
||||
|
||||
|
||||
_EXPR = re.compile(r"\$\{\{(.*?)\}\}", re.S)
|
||||
|
||||
# Roots of a dotted context path, and the callable functions. Both lists are what the runner
|
||||
# actually accepts; anything outside them cannot evaluate, and an expression that cannot evaluate
|
||||
# does not fail loudly — it silently removes the step it appears in.
|
||||
_EXPR_CONTEXTS = frozenset({
|
||||
"github", "env", "vars", "secrets", "inputs", "runner", "steps", "needs", "matrix", "job",
|
||||
"jobs", "strategy",
|
||||
})
|
||||
_EXPR_FUNCTIONS = frozenset({
|
||||
"always", "success", "failure", "cancelled", "hashFiles", "format", "toJSON", "toJson",
|
||||
"fromJSON", "fromJson", "contains", "startsWith", "endsWith", "join",
|
||||
})
|
||||
_EXPR_LITERALS = frozenset({"true", "false", "null"})
|
||||
|
||||
|
||||
def _yaml_string_scalars(path: Path):
|
||||
"""Every string scalar in the parsed document — keys and values, recursively.
|
||||
|
||||
Deliberately PARSED rather than raw (ersatztv#751, cold re-review). A `${{ … }}` in an ordinary
|
||||
top-level YAML comment is inert: the runner never evaluates it, so redding on it would be a false
|
||||
positive of exactly the kind this file has now produced twice. PyYAML drops those comments, which
|
||||
is the behaviour wanted here.
|
||||
|
||||
A `run:` body IS one of these scalars, and it keeps its SHELL comments — which is the whole point,
|
||||
because inside a `run:` scalar a comment is not inert. So this covers the real defect class in
|
||||
every workflow while ignoring the one place a delimiter is genuinely harmless.
|
||||
"""
|
||||
import yaml
|
||||
out: list[str] = []
|
||||
|
||||
def walk(node):
|
||||
if isinstance(node, str):
|
||||
out.append(node)
|
||||
elif isinstance(node, dict):
|
||||
for k, v in node.items():
|
||||
if isinstance(k, str):
|
||||
out.append(k)
|
||||
walk(v)
|
||||
elif isinstance(node, list):
|
||||
for v in node:
|
||||
walk(v)
|
||||
|
||||
walk(yaml.safe_load(path.read_text()))
|
||||
return out
|
||||
|
||||
|
||||
def _workflow_files():
|
||||
# `*.y*ml` for the reason test_no_OTHER_workflow_writes_the_review_verdict_status gives: a
|
||||
# workflow added as `.yaml` is just as executable and would otherwise go unscanned.
|
||||
return sorted((REPO_ROOT / ".gitea" / "workflows").glob("*.y*ml"))
|
||||
|
||||
|
||||
def test_the_verdict_workflow_has_NO_expression_delimiter_in_any_run_body():
|
||||
"""The absolute rule, and it is scoped to this one file on purpose.
|
||||
|
||||
Other workflows legitimately interpolate into a `run:` body (5 occurrences today across
|
||||
ci-image, docker-build's `api-docs`/`format`, and pr-checks' two git-diff gates), so a
|
||||
repo-wide ban would be false and would be deleted the first time it got in someone's way.
|
||||
`test`, `migrations` and `build` carry their own absolute ban — see
|
||||
scripts/tests/test_ci_dropped_step_guard.py (ersatztv#756). This file is different in two ways that justify the
|
||||
strict rule: it writes the branch-protection-required status, so a dropped step here is a dead
|
||||
merge gate rather than a failed build; and its `run:` bodies are ~700 lines of dense prose,
|
||||
which is the only place the delimiter has ever appeared by accident.
|
||||
|
||||
The rule is also what keeps the dropped-step guard trustworthy. A guard that the guarded
|
||||
mechanism can silently delete is worse than no guard, because its absence is silent too.
|
||||
|
||||
The `run:` SCALAR AS PARSED, comments and all — never `_code_lines`, which strips them. The
|
||||
distinction matters and is easy to garble: what must not be filtered is the SHELL comments inside
|
||||
the body, because those are what the runner scans. Ordinary YAML comments outside a `run:` body are
|
||||
genuinely inert and are handled by the repo-wide test below, which is why neither test reads the
|
||||
file as flat text any more.
|
||||
"""
|
||||
offenders = []
|
||||
for job_name, step in _iter_workflow_steps(WORKFLOW):
|
||||
for m in _EXPR.finditer(step.get("run") or ""):
|
||||
offenders.append(f"{job_name}/{step.get('name', '?')}: {m.group(1).strip()!r}")
|
||||
assert not offenders, (
|
||||
f"review-verdict.yml has an expression delimiter inside a run: body — {offenders}. Even in "
|
||||
"a comment this is unsafe: the runner rewrites the WHOLE body into a format(...) call, and "
|
||||
"if the payload does not parse it drops the step and reports the job GREEN, leaving the "
|
||||
"required review-verdict/h10 unposted (ersatztv#751). To describe an expression in prose, "
|
||||
"name it (`a github.event.pull_request.number expression`) instead of quoting the "
|
||||
"delimiters. Pass values in through the step's `env:` block, which is interpolated per "
|
||||
"value, so a bad payload there cannot take the body with it."
|
||||
)
|
||||
# ANTI-VACUITY, and it has to be the right property. A `run:` body the YAML walk never reached
|
||||
# would make the assertion above vacuously green — the failure mode to guard against. An earlier
|
||||
# version of this guard compared the file's TOTAL delimiter count against the count inside
|
||||
# `with:`/`env:` values, which is a different and wrong claim: it banned expressions everywhere
|
||||
# else in the file too. Both reviewers reproduced the false red — writing `if: ${{ always() }}`,
|
||||
# the standard and equivalent spelling of the `if:` two steps below, turned this test red, as did
|
||||
# a delimiter in an inert top-level YAML comment. Neither is unsafe, and a red here blocks every
|
||||
# merge through the combined status, so the guard was strictly more dangerous than the thing it
|
||||
# was protecting against.
|
||||
#
|
||||
# ANTI-VACUITY WITHOUT A SECOND PARSER. A first attempt counted `run:` keys in the raw text and
|
||||
# compared that to the walk. Cold re-review showed the regex only recognised an indented `run:`
|
||||
# whose value starts with `|` or `>`, so legal spellings (`- run: |`, a single-line
|
||||
# `run: echo ok`) counted as zero declarations and false-redded the file, while a `run: |` line
|
||||
# sitting INSIDE a shell heredoc counted as a declaration. Hand-parsing YAML to check a YAML parse
|
||||
# is the wrong shape: it adds a second, worse parser whose disagreements are all false alarms, and
|
||||
# a red here blocks every merge through the combined status.
|
||||
#
|
||||
# The property that actually matters is that the walk reached the body that carries the risk. The
|
||||
# classifier is ~700 lines; a walk that returned nothing, or only the short steps, is the failure
|
||||
# to catch. Both are asserted on content, which no spelling change can spoof.
|
||||
bodies = [s["run"] for _, s in _iter_workflow_steps(WORKFLOW) if s.get("run")]
|
||||
# NOT `>= 3`, and not merely non-empty either. `>= 3` had zero slack — cold review deleted the
|
||||
# optional jq-preflight step, a legitimate simplification, and this redded claiming the classifier
|
||||
# had not been examined, which was untrue. But relaxing it to `assert bodies` threw away the only
|
||||
# check that the walk reached ALL run-bearing steps: `max(len) > 5000` proves it reached the
|
||||
# classifier and nothing about the short ones, so a helper that silently stopped yielding them
|
||||
# would let an unscanned delimiter through.
|
||||
#
|
||||
# So count against the job's own step list, read here rather than through the helper under test.
|
||||
# That catches the failure the count existed for (a helper looking at the wrong key, or dropping
|
||||
# steps) without breaking when a step is legitimately added or removed.
|
||||
import yaml as _yaml
|
||||
_steps = (_yaml.safe_load(WORKFLOW.read_text())["jobs"]["set-verdict-status"]["steps"] or [])
|
||||
declared = sum(1 for s in _steps if isinstance(s, dict) and s.get("run"))
|
||||
assert len(bodies) == declared, (
|
||||
f"the YAML walk reached {len(bodies)} run: bodies but the job declares {declared} — the "
|
||||
"assertion above did not examine every body, so a green here proves nothing")
|
||||
assert max(len(b) for b in bodies) > 5000, (
|
||||
"the YAML walk did not reach a substantial run: body — the ~700-line classifier is the one "
|
||||
"that must be scanned, so this test would be vacuous")
|
||||
|
||||
|
||||
def _iter_workflow_steps(path: Path):
|
||||
import yaml
|
||||
doc = yaml.safe_load(path.read_text()) or {}
|
||||
for job_name, job in (doc.get("jobs") or {}).items():
|
||||
for step in (job.get("steps") or []):
|
||||
yield job_name, step
|
||||
|
||||
|
||||
def test_every_workflow_expression_names_a_REAL_context_or_function():
|
||||
"""The general form of #751, across every workflow and every field.
|
||||
|
||||
The strict test above hardens the gate file. It cannot see the class, which is not "prose in a
|
||||
run body" but "a payload that does not evaluate" — a typo in an `if:`, a renamed output, a
|
||||
context that does not exist. All of them fail the same silent way, and in an `if:` the
|
||||
consequence is the same shape as #751: the step does not run and nothing is red.
|
||||
|
||||
BE PRECISE ABOUT WHAT THIS ENFORCES, because the first version of this docstring was not and
|
||||
both reviewers caught it: it checks that THE HEAD TOKEN of each dotted path is a known context or
|
||||
function. Nothing more. That catches the historical defect — `pr number` fails on `pr` — and a
|
||||
payload naming a context that does not exist. It does NOT catch:
|
||||
|
||||
* syntactically invalid expressions whose tokens are all known: `${{ github.ref == }}` and
|
||||
`${{ github.event.pull_request.head.sha + }}` both pass, verified;
|
||||
* a renamed or misspelled output or property, because every token after the first is preceded
|
||||
by `.` and is deliberately skipped: `steps.metadata.outputs.shortsha` passes;
|
||||
* an unclosed opener, since `_EXPR` requires the closing braces to match at all.
|
||||
|
||||
A real fix for those is an expression parser, which is a different and much larger change. This is
|
||||
a cheap net under the specific class that has bitten us, deliberately kept permissive so it cannot
|
||||
false-red the repo (a red here blocks every merge through the combined status). Verified against
|
||||
all 31 distinct payloads in this repo today, which pass. Do not restate this test as "catches any
|
||||
payload that cannot evaluate" — that claim was in the docs and the decision record and was false.
|
||||
"""
|
||||
offenders = []
|
||||
for wf in _workflow_files():
|
||||
for scalar in _yaml_string_scalars(wf):
|
||||
for m in _EXPR.finditer(scalar):
|
||||
payload = m.group(1).strip()
|
||||
# Strip string literals first: a path inside `hashFiles('web/package-lock.json')` is
|
||||
# data, not an identifier, and would otherwise read as an unknown context.
|
||||
bare = re.sub(r"'[^']*'", "''", payload)
|
||||
for ident in re.finditer(r"(?<![.\w'])([A-Za-z_][A-Za-z0-9_-]*)", bare):
|
||||
name = ident.group(1)
|
||||
if name in _EXPR_CONTEXTS or name in _EXPR_FUNCTIONS or name in _EXPR_LITERALS:
|
||||
continue
|
||||
offenders.append(f"{wf.name}: {payload!r} -> unknown '{name}'")
|
||||
assert not offenders, (
|
||||
"these workflow expressions name something the runner cannot resolve, so they will fail to "
|
||||
f"interpolate — which DROPS THE STEP and still reports the job green (ersatztv#751): "
|
||||
f"{offenders}. If this is prose describing an expression, do not write the delimiters; if "
|
||||
"it is a real new context or function, add it to _EXPR_CONTEXTS / _EXPR_FUNCTIONS here."
|
||||
)
|
||||
|
||||
|
||||
def test_a_dropped_classify_step_FAILS_the_job_instead_of_going_green():
|
||||
"""The silent-green half of #751 — the actual defect, and the only part that generalises.
|
||||
|
||||
The stray delimiter was one bug in one comment. What made it cost three days of a dead merge
|
||||
gate was the REPORTING: a dropped step concludes `success`, the workflow's own status context
|
||||
goes green, and the required `review-verdict/h10` is merely ABSENT — which on a normal PR is
|
||||
indistinguishable from the correct "not reviewed yet" state. Nothing surfaced it. So the
|
||||
classifier writes a start marker and a later `if: always()` step fails the job when it is
|
||||
missing.
|
||||
|
||||
Asserted structurally, and the marker PATH is compared across the two steps rather than
|
||||
hardcoded here twice: the failure mode of a divergent path is a job that reddens on every run
|
||||
(fail-closed, but it would look like this guard is broken rather than like a real drop), and a
|
||||
test carrying its own third copy of the literal could not see the divergence at all.
|
||||
|
||||
The premise — that the runner still executes a LATER step after dropping an earlier one — is
|
||||
MEASURED, not assumed, and could not be settled from the #751 report because the classifier was
|
||||
the job's last step. Gitea 1.27.1, 2026-08-06: probe run 1863 dropped the classifier on a
|
||||
reintroduced bad payload, ran the guard anyway (`always()` evaluated true), and the job concluded
|
||||
`failure`; run 1866 is the positive control. So the guard is load-bearing in production and the
|
||||
body does not need to move into `scripts/`. This test pins the SHAPE; the probe pinned the
|
||||
BEHAVIOUR, and neither substitutes for the other — which is why the behavioural test below
|
||||
EXECUTES the guard body rather than only reading it.
|
||||
"""
|
||||
# ONE parse, and both steps located inside it. Using `_classify_step()` here instead was wrong
|
||||
# in a way worth recording, because it went green-adjacent rather than loud: that helper does its
|
||||
# own `yaml.safe_load`, so the dict it returns is never the same OBJECT as the equivalent step in
|
||||
# this list. An `is not` filter against it therefore excluded nothing, the classify step matched
|
||||
# as its own guard, and the assertions below ran against the wrong step.
|
||||
steps = [s for _, s in _iter_workflow_steps(WORKFLOW)]
|
||||
classifiers = [i for i, s in enumerate(steps) if "review-verdict/h10" in (s.get("run") or "")]
|
||||
assert classifiers, "no step in review-verdict.yml posts review-verdict/h10"
|
||||
classify = steps[classifiers[0]]
|
||||
# The FULL path expansion is compared between the two steps, not a basename. The path is keyed on
|
||||
# the run id, so a basename match would accept two steps that agree on the prefix and disagree on
|
||||
# the key — which is precisely the divergence that would make the guard fail on every run.
|
||||
marker_write = re.search(r'(\w+)="(\$\{RUNNER_TEMP[^"]*)"', classify["run"])
|
||||
assert marker_write, (
|
||||
"the classify step no longer assigns a start-marker path under RUNNER_TEMP, so a step the "
|
||||
"runner drops goes green again (ersatztv#751)")
|
||||
var, marker_name = marker_write.group(1), marker_write.group(2)
|
||||
assert re.search(rf':\s*>\s*"\${var}"', classify["run"]), (
|
||||
f"the classify step defines {var} but never creates the marker, so the guard below will "
|
||||
"fail on every run and read as broken rather than as a real dropped step")
|
||||
|
||||
guard_idx = [i for i, s in enumerate(steps)
|
||||
if i != classifiers[0] and marker_name in (s.get("run") or "")]
|
||||
assert guard_idx, (
|
||||
f"no step checks for the {marker_name!r} start marker. Without it a dropped classify step "
|
||||
"concludes success and the merge gate is silently dead (ersatztv#751)")
|
||||
# AFTER the classifier, not merely present. A guard placed before it would read a marker that
|
||||
# has not been written yet and fail on every run — fail-closed, but it would deadlock `main` and
|
||||
# read as this guard being broken, which is how a correct-looking guard gets deleted.
|
||||
assert guard_idx[0] > classifiers[0], (
|
||||
f"the dropped-step guard is step {guard_idx[0]} but the classifier is step "
|
||||
f"{classifiers[0]} — a guard that runs first always fails")
|
||||
guard = steps[guard_idx[0]]
|
||||
# `always()` and `${{ always() }}` are the same condition; the runner accepts both and the second
|
||||
# is the more common spelling. Pinning the bare form EXACTLY would red the repo over a
|
||||
# semantically identical edit, so normalise instead. What must not change is that the guard runs
|
||||
# when the classifier failed.
|
||||
guard_if = re.sub(r"\s+", "", str(guard.get("if", "")))
|
||||
assert guard_if in ("always()", "${{always()}}"), (
|
||||
f"the dropped-step guard's `if:` is {guard.get('if')!r}; it must be `always()` (bare or "
|
||||
"wrapped), or it will be skipped on exactly the runs where the classifier failed")
|
||||
# INSIDE the missing-marker branch, not merely somewhere in the body. Cold review pointed out
|
||||
# that a bare `exit 1` substring is satisfied by an unreachable `if false; then exit 1; fi` while
|
||||
# the real branch says `exit 0` — the test passes and a dropped classifier goes green again. The
|
||||
# behavioural test below is the real proof; this keeps the structural one from being satisfiable
|
||||
# by dead code.
|
||||
missing_branch = re.search(r'if \[ ! -f "\$marker" \]; then(.*?)\bfi\b', guard["run"], re.S)
|
||||
assert missing_branch, (
|
||||
"the dropped-step guard no longer tests for a MISSING marker with `if [ ! -f \"$marker\" ]`, "
|
||||
"so the assertion below cannot locate the branch that must fail the job")
|
||||
assert re.search(r"exit\s+1", missing_branch.group(1)), (
|
||||
"the dropped-step guard detects the missing marker but does not `exit 1` inside that branch, "
|
||||
"so it observes the failure and still lets the job go green — which is the whole defect")
|
||||
assert not _EXPR.search(guard["run"]), (
|
||||
"the dropped-step guard's own run body contains an expression delimiter, so the mechanism "
|
||||
"it guards against can drop the guard too — and that absence would be silent as well")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("terminator", ["null", "[]"], ids=["null-page", "empty-array-page"])
|
||||
def test_the_fence_TRUSTS_the_count_and_POSTS_when_the_timeline_terminates(tmp_path, terminator):
|
||||
"""The second defect found by the ersatztv#751 probe, and the one that actually kept the gate
|
||||
from posting anything.
|
||||
|
||||
A page past the end of `/issues/{n}/timeline` is the JSON value `null` on this instance, not `[]`.
|
||||
`count_retargets` gated on `type == "array"` and so treated the real terminator as unreadable: the
|
||||
walk never reached a validated empty page, `rt_ok` was never `yes` for ANY pull request, and the
|
||||
fence therefore withheld every exemption `success`. Renovate and docs-only PRs got NO status —
|
||||
the same user-visible outcome as #751, by an unrelated route.
|
||||
|
||||
It hid for two reasons worth keeping written down. It shipped in the same commit (8f6d4f443) that
|
||||
stopped the step from executing, so the fence had never once run in production; and the test
|
||||
double printed `[]` while its comment claimed to mirror measured reality, so the type gate was
|
||||
never exercised by the suite either. A green suite over an unfaithful double is what let a
|
||||
fail-closed-by-accident branch look deliberate.
|
||||
|
||||
Both shapes are asserted because both are live on this server: comments really do return `[]`.
|
||||
Asserting the POSTED STATUS, not the log, because the log said `Decision: state=success` on the
|
||||
real probe run and the job still posted nothing — the decision and the write are different events,
|
||||
and only the write is what a merge reads.
|
||||
"""
|
||||
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), timeline_mode="stable:0",
|
||||
timeline_terminator=terminator)
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert posted is not None, (
|
||||
f"a docs-only PR whose timeline terminates with `{terminator}` got NO status at all. The "
|
||||
"fence could not establish a trusted retarget count, so it withheld the exemption — which "
|
||||
"leaves the required review-verdict/h10 absent and the PR unmergeable with no bypass "
|
||||
f"(ersatztv#751).\n{r.stdout[-1200:]}")
|
||||
assert posted["state"] == "success", f"expected the docs-only exemption, got {posted}"
|
||||
assert "trusted=yes" in r.stdout, (
|
||||
"the exemption was posted but the fence did not report a trusted count — the two must agree, "
|
||||
f"or this test is passing for a different reason than it claims.\n{r.stdout[-800:]}")
|
||||
|
||||
|
||||
def _marker_steps():
|
||||
"""(classify body PREFIX through the marker write, guard body that CHECKS it) — from one parse.
|
||||
|
||||
THE PREFIX, NOT THE MATCHING LINES. An earlier version picked out the lines matching `RAN_MARKER=`
|
||||
and `: > "$RAN_MARKER"` and ran those two alone. Cold re-review showed that passes even if the
|
||||
write is moved into a function nobody calls, or under `if false`: the extractor finds the text,
|
||||
runs it at top level, the marker appears, and the test reports the guard proven while production
|
||||
writes no marker at all. Executing the real prefix — everything from the top of the body down to
|
||||
and including the write — reproduces the production control flow instead of a reconstruction of
|
||||
it, so a write that is defined-but-not-reached simply does not happen and case B fails.
|
||||
#
|
||||
It also pins the property the marker needs anyway: that the write happens EARLY, before anything
|
||||
in the classifier can fail. If it drifts down past code that needs credentials or network, this
|
||||
prefix stops executing cleanly and the test says so.
|
||||
"""
|
||||
steps = [s for _, s in _iter_workflow_steps(WORKFLOW)]
|
||||
idx = [i for i, s in enumerate(steps) if "review-verdict/h10" in (s.get("run") or "")]
|
||||
classify = steps[idx[0]]
|
||||
lines = classify["run"].splitlines()
|
||||
write_at = [i for i, ln in enumerate(lines) if re.match(r'\s*:\s*>\s*"\$RAN_MARKER"', ln)]
|
||||
assert len(write_at) == 1, (
|
||||
f"expected exactly one `: > \"$RAN_MARKER\"` in the classify body, found {len(write_at)}")
|
||||
prefix = "\n".join(lines[:write_at[0] + 1])
|
||||
guard = next(s for i, s in enumerate(steps)
|
||||
if i != idx[0] and "h10-classifier-started" in (s.get("run") or ""))
|
||||
return prefix, guard["run"]
|
||||
|
||||
|
||||
def test_the_dropped_step_guard_BEHAVIOURALLY_fails_without_the_marker_and_passes_with_it(tmp_path):
|
||||
"""Executes the guard, instead of reading it — the structural test above cannot prove the exit
|
||||
code, and cold review was right that a bare `exit 1` substring is satisfiable by dead code.
|
||||
|
||||
The marker is created by running THE CLASSIFY STEP'S OWN two prologue lines under the same
|
||||
environment, never by rebuilding the path in Python. That is the point: it proves the two steps
|
||||
AGREE on the path by construction. A test that computed the name itself would keep passing after
|
||||
the two steps drifted apart, which is the one divergence that makes the guard fail on every run
|
||||
and get deleted as broken.
|
||||
|
||||
`GITHUB_RUN_ID`/`GITHUB_RUN_ATTEMPT` are set to fixed values, so this also covers the run-keying
|
||||
added after the live probes: if either step stopped interpolating them, the paths would differ and
|
||||
case B would fail.
|
||||
"""
|
||||
prologue, guard = _marker_steps()
|
||||
assert prologue.count("RAN_MARKER=") == 1 and ': > "$RAN_MARKER"' in prologue, (
|
||||
f"could not extract the classify step's marker prologue; got {prologue!r}")
|
||||
# The prefix must be the REAL top of the body, so a write hidden in an uncalled function is not
|
||||
# executed by this test either.
|
||||
assert prologue.lstrip().startswith("set -euo pipefail"), (
|
||||
"the extracted prefix does not start at the top of the classify body, so it is a "
|
||||
f"reconstruction rather than the production path: {prologue[:120]!r}")
|
||||
env = {"PATH": os.environ["PATH"], "RUNNER_TEMP": str(tmp_path),
|
||||
"GITHUB_RUN_ID": "424242", "GITHUB_RUN_ATTEMPT": "7"}
|
||||
|
||||
# A — the step was DROPPED: no marker exists. The job must fail.
|
||||
a = subprocess.run(["bash", "-c", guard], env=env, capture_output=True, text=True)
|
||||
assert a.returncode != 0, (
|
||||
"the guard exited 0 with NO start marker present — a dropped classify step would go green "
|
||||
f"again, which is the whole defect (ersatztv#751).\nstdout: {a.stdout}\nstderr: {a.stderr}")
|
||||
assert "did not execute" in (a.stdout + a.stderr), (
|
||||
f"the guard failed but without an actionable message: {a.stdout!r} {a.stderr!r}")
|
||||
assert not list(tmp_path.glob("h10-classifier-started*")), (
|
||||
"the guard itself created the marker it is supposed to be checking for")
|
||||
|
||||
# B — the classifier RAN: its own prologue created the marker. The guard must pass.
|
||||
b = subprocess.run(["bash", "-c", prologue + "\n" + guard], env=env,
|
||||
capture_output=True, text=True)
|
||||
assert b.returncode == 0, (
|
||||
"the guard rejected a marker written by the classify step's OWN prologue — the two steps "
|
||||
f"disagree on the path, so this guard would fail on every run.\nstdout: {b.stdout}\n"
|
||||
f"stderr: {b.stderr}")
|
||||
# C — the write must be reached by STRAIGHT-LINE code. Guarding the prefix trick itself: if the
|
||||
# write were wrapped in a function or an `if`, the prefix would still contain it but production
|
||||
# might not reach it. Executing the prefix with the function/conditional intact is the test; this
|
||||
# assertion makes the intent explicit and fails loudly rather than subtly.
|
||||
body_before = "\n".join(ln for ln in prologue.splitlines()
|
||||
if not ln.lstrip().startswith("#"))
|
||||
# Covers all four bash spellings: `mk() {`, `mk(){`, `function mk {`, `function mk() {`. The third
|
||||
# was added when review found the first regex missed it, and the FOURTH still slipped that fix —
|
||||
# the union form is the natural next spelling once `function mk {` is caught. Budget three rounds
|
||||
# for any string-matching predicate.
|
||||
assert not re.search(r"^\s*(function\s+)?\w+\s*(\(\s*\))?\s*\{", body_before, re.M), (
|
||||
"a function is defined before the marker write, so the write may be inside it and unreached "
|
||||
f"in production while this test still passes:\n{body_before}")
|
||||
|
||||
written = [q.name for q in tmp_path.glob("h10-classifier-started*")]
|
||||
assert written == ["h10-classifier-started-424242-7"], (
|
||||
f"the marker is not keyed on the run id/attempt as intended; found {written}. A fixed name in "
|
||||
"a shared RUNNER_TEMP lets a stale marker satisfy this guard on a run whose step was dropped")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", ["null", "array"],
|
||||
ids=["statuses-null", "statuses-empty-array"])
|
||||
def test_a_head_with_NO_statuses_YET_is_readable_and_still_gets_its_exemption(tmp_path, shape):
|
||||
"""The twin of the timeline terminator, found by cold review of the fix for that one (#751).
|
||||
|
||||
`GET /commits/{sha}/status` returns `{"state":"pending","total_count":0,"statuses":null}` for a
|
||||
head that has no statuses yet — measured on PR #739's head 5fa672e2. `read_existing_verdict`
|
||||
gated on `.statuses | type == "array"`, so it read that as unreadable and took its `exit 1`
|
||||
path: the job posted NOTHING. Fail-closed, but the user-visible result is exactly the outcome
|
||||
this issue is about — an exempt PR with no status and, since #743, no bypass.
|
||||
|
||||
Both shapes are asserted because a head that HAS statuses really does return an array, so the job
|
||||
must read either. Restoring the `array`-only gate turns 40+ tests red with the corrected double,
|
||||
which is the measure of how thoroughly the unfaithful stub was hiding this.
|
||||
"""
|
||||
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_empty_shape=shape)
|
||||
assert r.returncode == 0, (
|
||||
f"the classifier died reading a `statuses: {shape}` body instead of treating it as "
|
||||
f"'no verdict yet', so nothing was posted at all.\n{r.stdout[-1000:]}\n{r.stderr[-600:]}")
|
||||
assert posted is not None, (
|
||||
f"a docs-only PR whose head has no statuses yet (shape: {shape}) got NO status at "
|
||||
f"all.\n{r.stdout[-1000:]}")
|
||||
assert posted["state"] == "success", f"expected the docs-only exemption, got {posted}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["CLAUDE.md", "AGENTS.md"])
|
||||
def test_the_GOVERNANCE_docs_are_protected_and_get_no_docs_only_exemption(tmp_path, path):
|
||||
"""`CLAUDE.md`/`AGENTS.md` are not prose about the project — they define the completion protocol,
|
||||
the merge-consent convention and the H10 rule itself. `DOCS_ONLY` (`^(docs/|[^/]*\\.md$)`) matched
|
||||
them, so a PR editing the document that specifies what `.claude/` enforces was auto-exemptible
|
||||
while `.claude/` itself was protected: the same self-exemption the workflow header rules out, one
|
||||
directory over. Found by cold review of #751 by driving this exact case through the real body.
|
||||
|
||||
Latent until #751, because no exemption `success` was writable at all while the classify step was
|
||||
dropped — restoring the exemptions is what makes it reachable, which is why it is fixed there.
|
||||
|
||||
Asserted through the PROTECTED branch specifically, not merely "not exempt": `pending` is reached
|
||||
by several routes and a test that accepted any of them could not tell a working guard from a dead
|
||||
one.
|
||||
"""
|
||||
posted, r = _run_classify(tmp_path, _emitting(path))
|
||||
assert posted is not None, f"the job posted nothing: {r.stderr[-1500:]}"
|
||||
assert posted["state"] == "pending", (
|
||||
f"{path} was granted an exemption ({posted}) — the document that DEFINES the merge gate must "
|
||||
f"not be able to exempt itself from it.\n{r.stdout[-800:]}")
|
||||
assert "protected" in r.stdout.lower(), (
|
||||
f"{path} was not exempted, but not via the protected-path branch either, so that guard may be "
|
||||
f"dead for it.\n{r.stdout[-800:]}")
|
||||
|
||||
|
||||
def test_a_terminator_on_PAGE_ONE_does_not_certify_a_zero_retarget_count(tmp_path):
|
||||
"""`null` means "exhausted" only after a real page has been read.
|
||||
|
||||
THE INVARIANT, not a figure that rots: a real PR's timeline always carries at least one event on
|
||||
page 1, because the PR is created by a push and that is itself an event. Spot-checked non-empty
|
||||
across #752/#753/#749/#739/#717; the counts are not recorded, because an earlier version of this
|
||||
docstring cited five and three were stale within days. So a terminator on page 1 is anomalous, not
|
||||
empty. Trusting a zero count from it would mean
|
||||
certifying that no retarget happened on the strength of a response we cannot explain, which is the
|
||||
one thing the fence exists to refuse. Withholding the exemption is the safe direction: the PR asks
|
||||
for a human verdict instead.
|
||||
|
||||
This narrows rather than closes the concern — a wrong `null` on page 3 is still read as exhaustion,
|
||||
and no bounded number of round-trips can rule that out.
|
||||
"""
|
||||
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), timeline_mode="empty-first-page")
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert posted is None, (
|
||||
"an exemption `success` was posted from a timeline whose FIRST page was already the "
|
||||
f"terminator, so no page of events was ever actually read: {posted}\n{r.stdout[-800:]}")
|
||||
assert "trusted=no" in r.stdout, (
|
||||
f"the exemption was withheld, but not because the count was untrusted:\n{r.stdout[-800:]}")
|
||||
|
||||
|
||||
def test_a_SECOND_PAGE_of_statuses_refuses_to_conclude_that_no_verdict_exists(tmp_path):
|
||||
"""Completeness for the "no verdict exists" conclusion — the one that licenses posting over a
|
||||
verdict this job cannot see.
|
||||
|
||||
THE TWO OBVIOUS GUARDS ARE BOTH NO-OPS HERE, which is why this costs a round-trip:
|
||||
|
||||
* `.statuses | length` vs `.total_count` — `total_count` is the count for the PAGE RETURNED, not
|
||||
the commit. Measured at 1.27.1 on 3aed43c6 (6 contexts): `?limit=1` -> `len=1, total_count=1`.
|
||||
Equal by construction. The stub mirrors that, so this test cannot pass for that wrong reason.
|
||||
* "refuse when the page came back full at the requested limit of 100" — this instance caps `limit`
|
||||
at `MAX_RESPONSE_ITEMS`, measured at 50 (`/issues?limit=100` returns 50), so a response can never
|
||||
hold 100 rows and the comparison was DEAD CODE. The repo already documented that cap in three
|
||||
places; the guard was written against 100 anyway and a cold review caught it.
|
||||
|
||||
So the job asks the server, and only when the row is absent from page 1. Any rows on page 2 mean
|
||||
the list is longer than one page and the verdict may be beyond it.
|
||||
"""
|
||||
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_mode="twopage")
|
||||
assert posted is None, (
|
||||
"an exemption was posted while the status list ran to a second page, so an existing verdict "
|
||||
f"beyond page 1 would have been silently overwritten: {posted}\n{r.stdout[-800:]}")
|
||||
assert "page 2" in (r.stdout + r.stderr).lower(), (
|
||||
f"nothing was posted, but not because of the page-2 completeness probe:\n{r.stdout[-800:]}")
|
||||
|
||||
|
||||
def test_a_SINGLE_page_of_statuses_reads_normally(tmp_path):
|
||||
"""Positive control for the probe above: without it, "refuse when page 2 has rows" could be
|
||||
satisfied by refusing always, which deadlocks every PR while looking safe. 40 decoy contexts on
|
||||
page 1, nothing on page 2.
|
||||
"""
|
||||
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_mode="rows:40")
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert posted is not None and posted["state"] == "success", (
|
||||
f"a single page of statuses should read normally and still exempt; got {posted}\n"
|
||||
f"{r.stdout[-800:]}")
|
||||
|
||||
|
||||
def test_a_STRING_total_count_is_not_accepted_as_numeric_zero(tmp_path):
|
||||
"""`jq -r` renders the JSON number 0 and the JSON string "0" identically, so a text compare
|
||||
accepts a schema-corrupted `"total_count": "0"` as "no statuses" (cold re-review reproduced it).
|
||||
|
||||
The live schema uses an integer, so this is not a live failure — it is the difference between a
|
||||
guard that holds because the input happens to be well-formed and one that holds because it checks.
|
||||
The accept path requires the TYPE to be number.
|
||||
"""
|
||||
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_mode="total-count-string")
|
||||
assert posted is None, (
|
||||
f"a string total_count was accepted as numeric zero, so a body that merely lost its statuses "
|
||||
f"array reads as 'no verdict exists': {posted}\n{r.stdout[-800:]}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode,why", [
|
||||
("page2-garbage", "a non-JSON page 2"),
|
||||
("page2-error", "an HTTP error on page 2"),
|
||||
], ids=["garbage", "transport-error"])
|
||||
def test_an_UNREADABLE_page_2_refuses_to_conclude_that_no_verdict_exists(tmp_path, mode, why):
|
||||
"""The two refuse branches of the completeness probe, which cold review found untested.
|
||||
|
||||
Worth a test rather than trusting the shape: this file's history is two consecutive guards that
|
||||
were UNREACHABLE — the `count_retargets` type gate that never saw a real terminator, and a
|
||||
full-page check written against a limit of 100 on a server that caps at 50. An unexercised branch
|
||||
here has a track record.
|
||||
|
||||
Both must fail CLOSED: the point of reading page 2 is to justify "no verdict exists", so a page 2
|
||||
that cannot be read justifies nothing.
|
||||
"""
|
||||
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_mode=mode)
|
||||
assert posted is None, (
|
||||
f"an exemption was posted despite {why}, so 'no verdict exists' was concluded without "
|
||||
f"evidence: {posted}\n{r.stdout[-800:]}")
|
||||
assert "page 2" in (r.stdout + r.stderr).lower(), (
|
||||
f"nothing was posted, but not because of the page-2 probe:\n{r.stdout[-800:]}")
|
||||
|
||||
|
||||
def test_a_status_history_RUNNING_PAST_PAGE_1_repairs_rather_than_leaving_green(tmp_path):
|
||||
"""The post-write race check reads ONE page of `/statuses/{sha}`, and `limit=100` clamps to the
|
||||
server-wide cap (measured 50). So "no raced row on page 1" does not establish "no race" — a raced
|
||||
human `failure` can sit on a page this job never reads.
|
||||
|
||||
This is the ONE path in the design whose failure direction is toward SUCCESS: missing a raced
|
||||
verdict leaves a forged green over a human rejection, permanently. So unread rows are treated as a
|
||||
race and the exemption is repaired to `pending`, which is the conservative direction — a stall a
|
||||
reviewer can clear, rather than a rejection silently turned green.
|
||||
|
||||
The stub's second page carries ORDINARY rows, no verdict and no sentinel: what must trigger the
|
||||
repair is the mere existence of rows this job did not read, not the discovery of a verdict.
|
||||
"""
|
||||
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), history_mode="second-page")
|
||||
assert r.returncode == 0, r.stderr
|
||||
seq = _posted_sequence(tmp_path)
|
||||
assert len(seq) == 2, (
|
||||
"the exemption was posted and left standing even though the status history ran past page 1, so "
|
||||
f"a raced verdict beyond it would be buried. Posts: {seq}\n{r.stdout[-900:]}")
|
||||
assert seq[0]["state"] == "success" and seq[1]["state"] == "pending", (
|
||||
f"expected an exemption then a repair to pending; got {seq}")
|
||||
assert seq[1]["description"] == "Human verdict raced this exemption write — re-post the verdict", (
|
||||
f"repaired, but not to the sentinel, so the fixed point is broken: {seq[1]}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode,why", [
|
||||
("second-page-garbage", "a non-JSON page 2"),
|
||||
("second-page-error", "an HTTP error on page 2"),
|
||||
], ids=["garbage", "transport-error"])
|
||||
def test_an_UNREADABLE_history_page_2_also_repairs_rather_than_leaving_green(tmp_path, mode, why):
|
||||
"""Found by mutating the branch and watching nothing go red — my own coverage gap, in the same
|
||||
class the review had just flagged twice.
|
||||
|
||||
This is the fail-toward-SUCCESS path, so uncertainty must resolve to `pending`. "I could not read
|
||||
the rest of the history" is not evidence that no verdict raced this write; treating it as such is
|
||||
exactly how a forged green survives over a human rejection.
|
||||
"""
|
||||
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), history_mode=mode)
|
||||
assert r.returncode == 0, r.stderr
|
||||
seq = _posted_sequence(tmp_path)
|
||||
assert len(seq) == 2, (
|
||||
f"the exemption was left standing despite {why} — a raced verdict beyond page 1 would be "
|
||||
f"buried. Posts: {seq}\n{r.stdout[-900:]}")
|
||||
assert seq[0]["state"] == "success" and seq[1]["state"] == "pending", (
|
||||
f"expected an exemption then a repair to pending; got {seq}")
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Tests for the tag-only-push exemption in `.claude/hooks/prepush-rebase-check.sh` (ersatztv#719).
|
||||
|
||||
H11 refuses to push a branch that is behind `origin/main`, to force a rebase instead of a merge.
|
||||
But the release cut tags a commit on `main` from a branch that is behind `origin/main`, so H11
|
||||
blocked every release -- and its "rebase first" advice did not even apply, because no branch was
|
||||
being pushed. (Observed while cutting v26.13.0; see #719. `docs/ci-cd.md` -> "Cutting a release"
|
||||
documents the tag step itself, not the release-notes-PR flow that puts the branch behind.) A tag
|
||||
push cannot revert anyone's merged work (the failure mode H11 exists to prevent), so the fix skips
|
||||
the freshness check when EVERY ref being pushed is under `refs/tags/`.
|
||||
|
||||
These tests use real local git repositories (a bare "origin" plus a work tree pushed one commit
|
||||
behind it) rather than stubbing `git`, because the hook's decision hinges on genuine
|
||||
`git fetch` / `merge-base` / `rev-list` behavior against an origin that has moved.
|
||||
|
||||
`test_zero_ref_lines_does_not_exempt` is the load-bearing negative case from the issue: "all pushed
|
||||
refs are tags" is vacuously true over zero ref lines, so a naive implementation would disable H11
|
||||
entirely whenever stdin is empty (hook run manually, or a caller that forgot to forward it). The fix
|
||||
must require at least one parsed ref line before granting the exemption.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
HOOK = REPO_ROOT / ".claude" / "hooks" / "prepush-rebase-check.sh"
|
||||
|
||||
DUMMY_SHA_A = "a" * 40
|
||||
DUMMY_SHA_B = "b" * 40
|
||||
|
||||
|
||||
def _git(args, cwd):
|
||||
r = subprocess.run(["git", *args], cwd=str(cwd), capture_output=True, text=True)
|
||||
assert r.returncode == 0, f"git {' '.join(args)} failed: {r.stderr}"
|
||||
return r.stdout
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def behind_repo(tmp_path):
|
||||
"""A work tree whose local `main` is exactly one commit behind `origin/main`."""
|
||||
origin = tmp_path / "origin.git"
|
||||
_git(["init", "--bare", "-q", str(origin)], cwd=tmp_path)
|
||||
|
||||
work = tmp_path / "work"
|
||||
_git(["init", "-q", "-b", "main", str(work)], cwd=tmp_path)
|
||||
_git(["config", "user.email", "test@example.com"], cwd=work)
|
||||
_git(["config", "user.name", "Test"], cwd=work)
|
||||
(work / "f.txt").write_text("one\n")
|
||||
_git(["add", "f.txt"], cwd=work)
|
||||
_git(["commit", "-q", "-m", "initial"], cwd=work)
|
||||
_git(["remote", "add", "origin", str(origin)], cwd=work)
|
||||
_git(["push", "-q", "-u", "origin", "main"], cwd=work)
|
||||
# The bare repo's HEAD symref still points at the (nonexistent) default branch until something
|
||||
# sets it explicitly; without this, `git clone` below checks out an unborn HEAD and "main" never
|
||||
# exists as a local branch in `advancer`.
|
||||
_git(["symbolic-ref", "HEAD", "refs/heads/main"], cwd=origin)
|
||||
|
||||
# Advance origin/main independently, via a second clone, so `work`'s local `main` falls behind.
|
||||
advancer = tmp_path / "advancer"
|
||||
_git(["clone", "-q", str(origin), str(advancer)], cwd=tmp_path)
|
||||
_git(["config", "user.email", "test@example.com"], cwd=advancer)
|
||||
_git(["config", "user.name", "Test"], cwd=advancer)
|
||||
(advancer / "f.txt").write_text("two\n")
|
||||
_git(["add", "f.txt"], cwd=advancer)
|
||||
_git(["commit", "-q", "-m", "advance"], cwd=advancer)
|
||||
_git(["push", "-q", "origin", "main"], cwd=advancer)
|
||||
|
||||
return work
|
||||
|
||||
|
||||
def _run_hook(cwd, stdin_text):
|
||||
env = dict(os.environ)
|
||||
for k in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"):
|
||||
env.pop(k, None)
|
||||
return subprocess.run(
|
||||
["bash", str(HOOK)],
|
||||
cwd=str(cwd),
|
||||
input=stdin_text,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def test_tag_only_push_from_a_behind_branch_is_allowed(behind_repo):
|
||||
"""The fix: a tag-only push must not be blocked by H11 even though the branch is behind."""
|
||||
stdin = f"refs/tags/v1.0.0 {DUMMY_SHA_A} refs/tags/v1.0.0 {DUMMY_SHA_B}\n"
|
||||
r = _run_hook(behind_repo, stdin)
|
||||
assert r.returncode == 0, f"tag-only push was blocked: {r.stdout}{r.stderr}"
|
||||
|
||||
|
||||
def test_tag_only_push_ignores_blank_lines(behind_repo):
|
||||
stdin = f"\nrefs/tags/v1.0.0 {DUMMY_SHA_A} refs/tags/v1.0.0 {DUMMY_SHA_B}\n\n"
|
||||
r = _run_hook(behind_repo, stdin)
|
||||
assert r.returncode == 0, f"tag-only push (with blank lines) was blocked: {r.stdout}{r.stderr}"
|
||||
|
||||
|
||||
def test_negative_control_branch_push_from_behind_is_still_blocked(behind_repo):
|
||||
"""Required by #719: the fix must not weaken H11 for ordinary branch pushes."""
|
||||
stdin = f"refs/heads/feature {DUMMY_SHA_A} refs/heads/feature {DUMMY_SHA_B}\n"
|
||||
r = _run_hook(behind_repo, stdin)
|
||||
assert r.returncode == 1, "a branch push from a behind branch was allowed"
|
||||
assert "H11" in r.stdout
|
||||
|
||||
|
||||
def test_mixed_branch_and_tag_push_is_still_blocked(behind_repo):
|
||||
stdin = (
|
||||
f"refs/heads/feature {DUMMY_SHA_A} refs/heads/feature {DUMMY_SHA_B}\n"
|
||||
f"refs/tags/v1.0.0 {DUMMY_SHA_A} refs/tags/v1.0.0 {DUMMY_SHA_B}\n"
|
||||
)
|
||||
r = _run_hook(behind_repo, stdin)
|
||||
assert r.returncode == 1, "a mixed branch+tag push was allowed through the tag exemption"
|
||||
assert "H11" in r.stdout
|
||||
|
||||
|
||||
def test_zero_ref_lines_does_not_exempt(behind_repo):
|
||||
"""Vacuous-truth guard: 'all refs are tags' is trivially true over zero lines. Empty stdin
|
||||
(hook run manually, or a caller that forgot to forward the ref lines) must fall through to the
|
||||
existing behind-origin/main check, not silently disable H11."""
|
||||
r = _run_hook(behind_repo, "")
|
||||
assert r.returncode == 1, "empty stdin vacuously granted the tag exemption"
|
||||
assert "H11" in r.stdout
|
||||
|
||||
|
||||
def test_zero_ref_lines_of_only_blank_lines_does_not_exempt(behind_repo):
|
||||
r = _run_hook(behind_repo, "\n\n\n")
|
||||
assert r.returncode == 1, "stdin of only blank lines vacuously granted the tag exemption"
|
||||
assert "H11" in r.stdout
|
||||
|
||||
|
||||
# --- final line with NO trailing newline -------------------------------------------------------
|
||||
# `read` returns non-zero on an unterminated final line, so a bare `while read` silently DROPS it.
|
||||
# Both directions matter and they fail differently, which is why each is pinned:
|
||||
# - tag-only, unterminated -> the line is dropped, no refs are seen, and H11 blocks the release
|
||||
# tag push again, i.e. #719 quietly returns.
|
||||
# - mixed, unterminated -> the BRANCH line is dropped, leaving only tag refs, and the
|
||||
# exemption is granted for a push that includes a branch. That is the dangerous direction.
|
||||
# Git always newline-terminates its ref lines and `.husky/pre-push` re-adds one via `printf '%s\n'`,
|
||||
# so this is reachable only on a hand-piped run — but the guard is cheap and the failure is silent.
|
||||
|
||||
|
||||
def test_unterminated_final_line_tag_only_is_still_exempt(behind_repo):
|
||||
stdin = f"refs/tags/v1.0.0 {DUMMY_SHA_A} refs/tags/v1.0.0 {DUMMY_SHA_B}" # no trailing \n
|
||||
r = _run_hook(behind_repo, stdin)
|
||||
assert r.returncode == 0, f"unterminated tag-only line was dropped, reinstating #719: {r.stdout}"
|
||||
|
||||
|
||||
def test_tty_stdin_does_not_hang_and_does_not_exempt(behind_repo):
|
||||
"""The hook gained a stdin reader in #719; before that it read nothing, and its own docs call
|
||||
'run by hand' a supported case. Without the `[ -t 0 ] ||` guard an interactive run blocks
|
||||
forever waiting on the terminal. A pty gives it a real TTY on fd 0; the `timeout` turns a
|
||||
regression into a clean failure instead of a hung CI job."""
|
||||
primary, secondary = os.openpty()
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["bash", str(HOOK)],
|
||||
cwd=str(behind_repo),
|
||||
stdin=secondary,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
pytest.fail("hook hung on TTY stdin — the `[ -t 0 ] ||` guard is missing or ineffective")
|
||||
finally:
|
||||
os.close(primary)
|
||||
os.close(secondary)
|
||||
# A TTY yields no ref lines, so this is the zero-line fall-through: H11 still applies.
|
||||
assert r.returncode == 1, "TTY stdin vacuously granted the tag exemption"
|
||||
assert "H11" in r.stdout
|
||||
|
||||
|
||||
def test_unterminated_final_branch_line_is_not_swallowed_into_the_exemption(behind_repo):
|
||||
"""The dangerous direction: if the unterminated BRANCH line is dropped, only tag refs remain
|
||||
and a branch push wins the tag exemption."""
|
||||
stdin = (
|
||||
f"refs/tags/v1.0.0 {DUMMY_SHA_A} refs/tags/v1.0.0 {DUMMY_SHA_B}\n"
|
||||
f"refs/heads/feature {DUMMY_SHA_A} refs/heads/feature {DUMMY_SHA_B}" # no trailing \n
|
||||
)
|
||||
r = _run_hook(behind_repo, stdin)
|
||||
assert r.returncode == 1, "an unterminated branch ref was swallowed into the tag exemption"
|
||||
assert "H11" in r.stdout
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
getLibraryBrowseItems,
|
||||
searchLibraryBrowseItems,
|
||||
searchLibraryPickerOptions,
|
||||
titleContainsQuery,
|
||||
LIBRARY_PICKER_LUCENE_SPECIALS,
|
||||
@@ -150,3 +151,53 @@ describe('searchLibraryPickerOptions (#651)', () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchLibraryBrowseItems (#685 — AddItemsDialog sibling of searchLibraryPickerOptions)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// The reviewer proved this helper was dead code to the suite: deleting its clamp, or deleting
|
||||
// its gate, both left the whole suite green. These three tests mirror the ones above for
|
||||
// searchLibraryPickerOptions so the same bound is pinned for the sibling helper.
|
||||
|
||||
it('#651 F4: CLAMPS an oversized pageSize rather than forwarding it', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 20000 }));
|
||||
|
||||
await searchLibraryBrowseItems('Episode', 'Alpha', 5000);
|
||||
|
||||
expect(browseUrl(fetchMock).searchParams.get('pageSize')).toBe(String(LIBRARY_PICKER_RESULTS));
|
||||
});
|
||||
|
||||
it('issues NO request for a query below the minimum length, and resolves an empty result', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 }));
|
||||
|
||||
expect(await searchLibraryBrowseItems('Episode', 'a')).toEqual({ items: [], totalCount: 0 });
|
||||
expect(await searchLibraryBrowseItems('Episode', ' ')).toEqual({ items: [], totalCount: 0 });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('compiles/escapes the trimmed query, and returns the FULL row (mediaType present) plus totalCount', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
jsonResponse({
|
||||
page: [{ id: 1, mediaItemId: 7, mediaType: 'Movie', title: 'Show Alpha' }],
|
||||
totalCount: 42
|
||||
})
|
||||
);
|
||||
|
||||
const result = await searchLibraryBrowseItems('Movie', ' Show Alpha ');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const url = browseUrl(fetchMock);
|
||||
expect(url.searchParams.get('query')).toBe(titleContainsQuery('Show Alpha'));
|
||||
expect(url.searchParams.get('mediaType')).toBe('Movie');
|
||||
expect(url.searchParams.get('pageNum')).toBe('0');
|
||||
expect(url.searchParams.get('pageSize')).toBe(String(LIBRARY_PICKER_RESULTS));
|
||||
// The reason this helper exists rather than reusing searchLibraryPickerOptions: the full row
|
||||
// (mediaType included), not the {id, name} shape.
|
||||
expect(result).toEqual({
|
||||
items: [{ id: 1, mediaItemId: 7, mediaType: 'Movie', title: 'Show Alpha' }],
|
||||
totalCount: 42
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,6 +111,41 @@ export function searchLibraryPickerOptions(
|
||||
);
|
||||
}
|
||||
|
||||
export interface LibraryBrowseSearchResult {
|
||||
items: LibraryBrowseItem[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
// Like `searchLibraryPickerOptions` above — same min-query gate, same clamp, same compiled query —
|
||||
// but for a MULTI-select caller (`CollectionsScreen`'s `AddItemsDialog`) that needs the full
|
||||
// `LibraryBrowseItem` row (mediaType + id, for `toAddItemsRequest`) rather than the `{id, name}`
|
||||
// shape a single-select `SearchPicker` renders, plus the response's `totalCount` so the caller can
|
||||
// surface how much of a match was actually returned. The gate/clamp/compile live HERE, not at the
|
||||
// call site, so no caller can accidentally skip them (§3b — "the bound belongs to the helper, not
|
||||
// the caller").
|
||||
export function searchLibraryBrowseItems(
|
||||
mediaType: LibraryBrowseMediaType,
|
||||
text: string,
|
||||
pageSize: number = LIBRARY_PICKER_RESULTS
|
||||
): Promise<LibraryBrowseSearchResult> {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length < LIBRARY_PICKER_MIN_QUERY) {
|
||||
return Promise.resolve({ items: [], totalCount: 0 });
|
||||
}
|
||||
|
||||
const boundedPageSize = Math.max(1, Math.min(Math.floor(pageSize), LIBRARY_PICKER_RESULTS));
|
||||
|
||||
return getLibraryBrowseItems({
|
||||
mediaType,
|
||||
pageNum: 0,
|
||||
pageSize: boundedPageSize,
|
||||
query: titleContainsQuery(trimmed)
|
||||
}).then((result) => ({
|
||||
items: result.page ?? [],
|
||||
totalCount: result.totalCount ?? 0
|
||||
}));
|
||||
}
|
||||
|
||||
export function messageFromLibraryBrowseError(error: unknown, fallback = 'Unable to load library items'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
|
||||
@@ -83,12 +83,15 @@ import { scanPageSizeSites } from './pageSizeScan';
|
||||
* mechanism, and the row bound is a property of the CODE rather than of a
|
||||
* caller's discipline (a clamp inside the shared helper for #651's
|
||||
* library picker; a fixed small constant at the inline preview sites).
|
||||
* Nothing is list-loaded, so there is no truncation to surface and the
|
||||
* absence of a truncation hint is correct — which is why such a site
|
||||
* cannot be filed under 'class-b', whose defining evidence IS a surfaced
|
||||
* `totalCount`. Applies only where the query is genuinely required: a
|
||||
* site that degrades to an unfiltered browse when the query is empty is
|
||||
* NOT search-bounded (see 'deviation').
|
||||
* Nothing is list-loaded, so a truncation hint is not REQUIRED here — but,
|
||||
* unlike 'class-b', it is not the defining evidence either: a search-bounded
|
||||
* site MAY surface its own per-kind cap (e.g. summed across kinds) once that
|
||||
* count is actionable, without that hint reclassifying it as 'class-b'. What
|
||||
* distinguishes the two classes is the SHAPE of the bound (one clamped
|
||||
* search request per settled query vs. one bounded page of a list), not
|
||||
* whether a hint is rendered. Applies only where the query is genuinely
|
||||
* required: a site that degrades to an unfiltered browse when the query is
|
||||
* empty is NOT search-bounded (see 'deviation').
|
||||
* - 'class-b' — one bounded page at (or under) the cap, with the real truncation
|
||||
* (`totalCount` vs items shown) surfaced to the user. Post-#651 this no
|
||||
* longer covers media-library pickers (those are 'search-bounded').
|
||||
@@ -163,6 +166,35 @@ const REGISTRY: RegistryEntry[] = [
|
||||
'correctly absent). The bound is a clamp, not a default: Math.min(pageSize, ' +
|
||||
'LIBRARY_PICKER_RESULTS) inside the helper, so a caller cannot widen it.'
|
||||
},
|
||||
{
|
||||
file: 'api/libraryBrowse.ts',
|
||||
kind: 'literal',
|
||||
value: 'boundedPageSize',
|
||||
classification: 'search-bounded',
|
||||
note:
|
||||
"searchLibraryBrowseItems — the #685 review fix that moved AddItemsDialog.runSearch's " +
|
||||
'pageSize call site out of screens/CollectionsScreen.tsx and into this shared helper (same ' +
|
||||
'gate/clamp/compile as searchLibraryPickerOptions above), so a multi-select caller needing ' +
|
||||
'full LibraryBrowseItem rows plus totalCount cannot skip the bound either. A deliberate ' +
|
||||
'second occurrence of the same (file, kind, value) identity — the multiset comparison ' +
|
||||
'requires it be discovered twice. No request is issued below LIBRARY_PICKER_MIN_QUERY (a ' +
|
||||
'blank form submit and a kind-chip click below the gate both resolve every kind to ' +
|
||||
'{items: [], totalCount: 0} via the HELPER\'s own gate — CollectionsScreen no longer keeps a ' +
|
||||
'second copy of this check; the #685 second review proved the two masked each other), the ' +
|
||||
'typed text is compiled via titleContainsQuery rather than forwarded raw, and each kind is ' +
|
||||
'bounded to one request per settled query at LIBRARY_PICKER_RESULTS rows. Unlike the #685 ' +
|
||||
'first fix, this is NOT "nothing left to hint at": the per-kind cap can still truncate the ' +
|
||||
"real match count below what totalCount reports, and AddItemsDialog now sums each kind's " +
|
||||
"totalCount and renders a 'Showing N of M' hint when it exceeds the rendered rows. The only " +
|
||||
'remaining client-side filter in AddItemsDialog (ADDABLE_TYPES.has(item.mediaType)) is inert, ' +
|
||||
'not a silent drop, and this is now enforced by the type system rather than by convention on ' +
|
||||
'BOTH ingress paths into the searched kinds — MediaKindFilter (the explicit-chip path) and ' +
|
||||
'DEFAULT_SEARCH_KINDS (the `all` fan-out) are each derived from ADDABLE_TYPE_LIST via ' +
|
||||
'`(typeof ADDABLE_TYPE_LIST)[number]`, so adding a non-addable kind to either is a compile ' +
|
||||
'error. Enforcing only the first was the #685 round-3 review finding: the hint sums ' +
|
||||
'PRE-filter totalCounts against POST-filter rows, so one unenforced ingress is enough to ' +
|
||||
'overstate it with every row of that kind dropped.'
|
||||
},
|
||||
{
|
||||
file: 'api/paging.ts',
|
||||
kind: 'shorthand',
|
||||
@@ -246,22 +278,6 @@ const REGISTRY: RegistryEntry[] = [
|
||||
'Playout block history: forwards a user-adjustable `pageSize` state (persisted, backed by a ' +
|
||||
'page-size <Select>) to a real pager keyed off the response totalCount.'
|
||||
},
|
||||
{
|
||||
file: 'screens/CollectionsScreen.tsx',
|
||||
kind: 'literal',
|
||||
value: '50',
|
||||
classification: 'deviation',
|
||||
issue: 685,
|
||||
note:
|
||||
'TRACKED §3b VIOLATION — #685. AddItemsDialog.runSearch is reachable with an EMPTY query ' +
|
||||
'(blank form submit, and a kind-chip click, which calls it immediately), and ' +
|
||||
'getLibraryBrowseItems omits a falsy query — so it degrades to an unfiltered 50-row window ' +
|
||||
'over the whole media-library type, per kind. Nothing surfaces it: totalCount is never read ' +
|
||||
'here and no hint renders, and merged.slice(0, 50) drops up to 100 of 150 fetched rows even ' +
|
||||
'for a real query. Under the cap, so #644 and #650 both missed it. NOT search-bounded (the ' +
|
||||
'query is not required) and NOT class-b (no hint) — labelling it either would make this ' +
|
||||
'registry vouch for behaviour that does not exist.'
|
||||
},
|
||||
{
|
||||
file: 'screens/FillerPresetsScreen.tsx',
|
||||
kind: 'literal',
|
||||
@@ -535,9 +551,11 @@ describe('pageSize call-site guard (#650)', () => {
|
||||
it("every 'deviation' entry names the issue tracking it", () => {
|
||||
const deviations = REGISTRY.filter((entry) => entry.classification === 'deviation');
|
||||
|
||||
// Anti-vacuity: if the deviations are ever all fixed, this must be deleted deliberately, not
|
||||
// silently pass over an empty list while claiming to enforce something.
|
||||
expect(deviations.length).toBeGreaterThan(0);
|
||||
// No live 'deviation' entries as of #685 (the last one — CollectionsScreen.tsx's raw-50 window
|
||||
// — was fixed and reclassified 'search-bounded' above). The anti-vacuity
|
||||
// `expect(deviations.length).toBeGreaterThan(0)` this comment used to enforce is deleted
|
||||
// DELIBERATELY here, per its own instruction, rather than left to silently pass over an empty
|
||||
// list — re-add it the day a new 'deviation' entry is registered.
|
||||
|
||||
for (const entry of deviations) {
|
||||
expect(entry.issue, `${entry.file}:${entry.value} is a deviation but names no tracking issue`).toEqual(
|
||||
@@ -546,10 +564,12 @@ describe('pageSize call-site guard (#650)', () => {
|
||||
expect(entry.issue!).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// The converse, so the field cannot drift into decoration: only a deviation carries one.
|
||||
for (const entry of REGISTRY.filter((e) => e.classification !== 'deviation')) {
|
||||
expect(entry.issue, `${entry.file}:${entry.value} is not a deviation but carries an issue`).toBeUndefined();
|
||||
}
|
||||
// The converse, so the field cannot drift into decoration: only a deviation carries one. With
|
||||
// `deviations` currently empty, the loop above evaluates nothing — so this single bidirectional
|
||||
// assertion is what actually gives the test teeth today: it fails the moment any non-deviation
|
||||
// entry picks up an `issue` field, or a 'deviation' entry is added without one (#685 review
|
||||
// finding 7).
|
||||
expect(REGISTRY.filter((entry) => entry.issue !== undefined)).toEqual(deviations);
|
||||
});
|
||||
|
||||
// Pins the report format, not the comparison key (#684 review M3): dropping the position from
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
LIBRARY_PICKER_LUCENE_SPECIALS,
|
||||
LIBRARY_PICKER_MIN_QUERY,
|
||||
LIBRARY_PICKER_RESULTS,
|
||||
titleContainsQuery
|
||||
} from '../api';
|
||||
import { CollectionsScreen } from './CollectionsScreen';
|
||||
|
||||
// The rule builder's field catalog normally comes from a live /api/v1/search/fields fetch; mock it
|
||||
@@ -401,7 +407,7 @@ describe('CollectionsScreen', () => {
|
||||
|
||||
const dialog = await openAddItemsDialog();
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||
target: { value: 'a' }
|
||||
target: { value: 'za' }
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
|
||||
|
||||
@@ -419,7 +425,7 @@ describe('CollectionsScreen', () => {
|
||||
|
||||
const dialog = await openAddItemsDialog();
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||
target: { value: 'a' }
|
||||
target: { value: 'za' }
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Seasons' }));
|
||||
|
||||
@@ -451,7 +457,7 @@ describe('CollectionsScreen', () => {
|
||||
|
||||
const dialog = await openAddItemsDialog();
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||
target: { value: 'a' }
|
||||
target: { value: 'za' }
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Song' }));
|
||||
|
||||
@@ -475,6 +481,362 @@ describe('CollectionsScreen', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it(
|
||||
'backspacing the query below the min-query length after a search keeps the rows, checkmarks, and Add count (#685 review finding 1)',
|
||||
async () => {
|
||||
mockAddItemsApi();
|
||||
|
||||
const dialog = await openAddItemsDialog();
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||
target: { value: 'za' }
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
|
||||
|
||||
expect(await within(dialog).findByText('Zathura')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(dialog).getByText('Zathura'));
|
||||
expect(within(dialog).getByRole('button', { name: /Add 1 item/ })).toBeInTheDocument();
|
||||
|
||||
// Backspace the LIVE input down to one character — below LIBRARY_PICKER_MIN_QUERY — WITHOUT
|
||||
// re-submitting. Before the fix, the render condition keyed the min-query guidance to the
|
||||
// live input alone, so this wiped the visible row list and its checkmark even though
|
||||
// `results` and `selected` were untouched in state.
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||
target: { value: 'z' }
|
||||
});
|
||||
|
||||
expect(within(dialog).getByText('Zathura')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('Adventure Time')).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).queryByText(`Type at least ${LIBRARY_PICKER_MIN_QUERY} characters to search.`)
|
||||
).not.toBeInTheDocument();
|
||||
const zathuraRow = within(dialog).getByText('Zathura').closest('button');
|
||||
expect(zathuraRow).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(within(dialog).getByRole('button', { name: /Add 1 item/ })).toBeInTheDocument();
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'sums totalCount across kinds and hints at truncation once it exceeds the rendered rows (#685 review finding 4)',
|
||||
{ timeout: 15000 },
|
||||
async () => {
|
||||
mockLargeLibraryApi();
|
||||
|
||||
const dialog = await openAddItemsDialog();
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||
target: { value: 'za' }
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
|
||||
|
||||
await within(dialog).findByText('Movie Item 1');
|
||||
|
||||
// 3 kinds (Movie, TelevisionShow, Artist) each report a 20,000-row totalCount but only
|
||||
// LIBRARY_PICKER_RESULTS (25) rows land per kind, so the sum (60,000) is far above the 75
|
||||
// rows actually rendered.
|
||||
expect(within(dialog).getByText(/Showing 75 of 60000 matches/)).toBeInTheDocument();
|
||||
}
|
||||
);
|
||||
|
||||
it('does not render a truncation hint when totalCount does not exceed the rendered rows', async () => {
|
||||
mockAddItemsApi();
|
||||
|
||||
const dialog = await openAddItemsDialog();
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||
target: { value: 'za' }
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
|
||||
|
||||
await within(dialog).findByText('Zathura');
|
||||
|
||||
expect(within(dialog).queryByText(/Showing .* of .* matches/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it(
|
||||
'labels the results with the query that produced them, and clearing without re-submitting keeps rows + label (#685 review)',
|
||||
async () => {
|
||||
mockAddItemsApi();
|
||||
|
||||
const dialog = await openAddItemsDialog();
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||
target: { value: 'za' }
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
|
||||
|
||||
expect(await within(dialog).findByText('Zathura')).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByText(
|
||||
(_content, element) => element?.tagName === 'P' && element.textContent === 'Results for “za”'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Clear the input WITHOUT re-submitting: the rows and their label must still describe the
|
||||
// original ("za") search — nothing says which query produced an on-screen row otherwise.
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||
target: { value: '' }
|
||||
});
|
||||
|
||||
expect(within(dialog).getByText('Zathura')).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByText(
|
||||
(_content, element) => element?.tagName === 'P' && element.textContent === 'Results for “za”'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'clearing the input and re-submitting shows the min-query guidance while the selection stays discoverable (#685 review)',
|
||||
async () => {
|
||||
mockAddItemsApi();
|
||||
|
||||
const dialog = await openAddItemsDialog();
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||
target: { value: 'za' }
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
|
||||
|
||||
expect(await within(dialog).findByText('Zathura')).toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByText('Zathura'));
|
||||
expect(within(dialog).getByRole('button', { name: /Add 1 item/ })).toBeInTheDocument();
|
||||
|
||||
// Clear the input and re-submit: the helper's gate resolves every kind to
|
||||
// { items: [], totalCount: 0 }, so the (now query-less) results are empty.
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||
target: { value: '' }
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(dialog).getByText(`Type at least ${LIBRARY_PICKER_MIN_QUERY} characters to search.`)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
// `selected` persists across queries (correct for a multi-select picker) — it must never be
|
||||
// invisible-but-counted, and the Add button's count is where that stays discoverable even
|
||||
// though the previously-ticked row isn't in the (now empty) result set.
|
||||
expect(within(dialog).getByRole('button', { name: /Add 1 item/ })).toBeInTheDocument();
|
||||
// And the "Results for" heading is gone, not showing a stale/misleading query.
|
||||
expect(within(dialog).queryByText(/^Results for /)).not.toBeInTheDocument();
|
||||
}
|
||||
);
|
||||
|
||||
it('clears the truncation hint on a failed search (#685 review)', async () => {
|
||||
const fetchMock = mockApi({
|
||||
onRequest: (url) => {
|
||||
if (url.startsWith('/api/v1/library/browse')) {
|
||||
return jsonResponse({ page: [], totalCount: 0 }, 500);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const dialog = await openAddItemsDialog();
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||
target: { value: 'za' }
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(dialog).getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
expect(within(dialog).queryByText(/Showing .* of .* matches/)).not.toBeInTheDocument();
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
|
||||
// A failed search must NOT fall into the min-query guidance branch: the user typed a valid
|
||||
// 2-character query and got a 500, so telling them to "type at least 2 characters" states
|
||||
// something false about what they just did. The error banner is the whole message (#685
|
||||
// review round 3 — that branch conflated "nothing searched yet" with "the last search failed").
|
||||
expect(
|
||||
within(dialog).queryByText(`Type at least ${LIBRARY_PICKER_MIN_QUERY} characters to search.`)
|
||||
).not.toBeInTheDocument();
|
||||
// Nor "No results", which asserts a search that COMPLETED and found nothing. Both empty-state
|
||||
// messages are suppressed on error, leaving the alert banner as the whole message. Pinned
|
||||
// positively as well as negatively so a refactor cannot satisfy this by rendering nothing at
|
||||
// all (#685 review round 4).
|
||||
expect(within(dialog).queryByText('No results — try a search above.')).not.toBeInTheDocument();
|
||||
expect(within(dialog).getByRole('alert')).toHaveTextContent(/500/);
|
||||
});
|
||||
|
||||
/* ---------- add-items search bounds (#685) ---------- */
|
||||
|
||||
function buildLibraryFixture(mediaType: string, count: number): { id: number; mediaItemId: number; mediaType: string; title: string }[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: index + 1,
|
||||
mediaItemId: index + 1,
|
||||
mediaType,
|
||||
title: `${mediaType} Item ${index + 1}`
|
||||
}));
|
||||
}
|
||||
|
||||
// Simulates a library with `rowsPerKind` rows of EACH media type (20,000 by default — large
|
||||
// enough that "the client bounds itself" is the only thing keeping this fast), honoring the
|
||||
// requested pageSize the way the real server-side clamp does (spa-conventions §3b).
|
||||
function mockLargeLibraryApi(rowsPerKind = 20000) {
|
||||
const fixtures = new Map<string, ReturnType<typeof buildLibraryFixture>>();
|
||||
|
||||
return mockApi({
|
||||
onRequest: (url) => {
|
||||
if (!url.startsWith('/api/v1/library/browse')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new URL(url, 'http://localhost').searchParams;
|
||||
const mediaType = params.get('mediaType') ?? 'Movie';
|
||||
const pageSize = Number(params.get('pageSize') ?? '0');
|
||||
|
||||
if (!fixtures.has(mediaType)) {
|
||||
fixtures.set(mediaType, buildLibraryFixture(mediaType, rowsPerKind));
|
||||
}
|
||||
|
||||
const fixture = fixtures.get(mediaType) ?? [];
|
||||
return jsonResponse({ page: fixture.slice(0, pageSize), totalCount: fixture.length });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function browseCallsOf(fetchMock: ReturnType<typeof mockApi>) {
|
||||
return fetchMock.mock.calls.filter(([u]) => u.toString().startsWith('/api/v1/library/browse'));
|
||||
}
|
||||
|
||||
it('issues zero requests for a blank submit, and renders the min-query guidance', async () => {
|
||||
const fetchMock = mockLargeLibraryApi();
|
||||
|
||||
const dialog = await openAddItemsDialog();
|
||||
|
||||
expect(
|
||||
within(dialog).getByText(`Type at least ${LIBRARY_PICKER_MIN_QUERY} characters to search.`)
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
|
||||
|
||||
// No await-for-settle race: a blank submit never issues a fetch, so there is nothing async
|
||||
// to wait on — assert immediately that the guidance is still the only thing shown.
|
||||
expect(
|
||||
within(dialog).getByText(`Type at least ${LIBRARY_PICKER_MIN_QUERY} characters to search.`)
|
||||
).toBeInTheDocument();
|
||||
expect(browseCallsOf(fetchMock).length).toBe(0);
|
||||
});
|
||||
|
||||
it(
|
||||
`a ${LIBRARY_PICKER_MIN_QUERY - 1}-character submit (one below the minimum) issues zero requests`,
|
||||
async () => {
|
||||
// §3b: unit-test the INCLUSIVE endpoint directly — existing coverage only exercised 0 chars
|
||||
// (blank submit) and a valid 2-char query, so a `>` for `>=` slip in the gate would have
|
||||
// passed the whole suite (#685 review finding 3).
|
||||
const fetchMock = mockLargeLibraryApi();
|
||||
|
||||
const dialog = await openAddItemsDialog();
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||
target: { value: 'z' }
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
|
||||
|
||||
expect(
|
||||
within(dialog).getByText(`Type at least ${LIBRARY_PICKER_MIN_QUERY} characters to search.`)
|
||||
).toBeInTheDocument();
|
||||
expect(browseCallsOf(fetchMock).length).toBe(0);
|
||||
}
|
||||
);
|
||||
|
||||
it('a kind-chip click below the min-query gate issues zero requests', async () => {
|
||||
const fetchMock = mockLargeLibraryApi();
|
||||
|
||||
const dialog = await openAddItemsDialog();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Song' }));
|
||||
|
||||
expect(
|
||||
within(dialog).getByText(`Type at least ${LIBRARY_PICKER_MIN_QUERY} characters to search.`)
|
||||
).toBeInTheDocument();
|
||||
expect(browseCallsOf(fetchMock).length).toBe(0);
|
||||
});
|
||||
|
||||
it(
|
||||
'a valid query issues exactly one request per kind, bounded to LIBRARY_PICKER_RESULTS, with the compiled query',
|
||||
{ timeout: 15000 },
|
||||
async () => {
|
||||
const fetchMock = mockLargeLibraryApi();
|
||||
|
||||
const dialog = await openAddItemsDialog();
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||
target: { value: 'za' }
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
|
||||
|
||||
await within(dialog).findByText('Movie Item 1');
|
||||
|
||||
const browseCalls = browseCallsOf(fetchMock);
|
||||
// DEFAULT_SEARCH_KINDS fan-out: Movie, TelevisionShow, Artist — exactly one request each,
|
||||
// never more.
|
||||
expect(browseCalls.length).toBe(3);
|
||||
|
||||
const kindsRequested = browseCalls.map(
|
||||
([u]) => new URL(u.toString(), 'http://localhost').searchParams.get('mediaType')
|
||||
);
|
||||
expect(new Set(kindsRequested)).toEqual(new Set(['Movie', 'TelevisionShow', 'Artist']));
|
||||
|
||||
for (const [u] of browseCalls) {
|
||||
const params = new URL(u.toString(), 'http://localhost').searchParams;
|
||||
expect(params.get('pageSize')).toBe(String(LIBRARY_PICKER_RESULTS));
|
||||
expect(params.get('query')).toBe(titleContainsQuery('za'));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'compiles typed text and escapes a Lucene special character rather than forwarding it raw',
|
||||
{ timeout: 15000 },
|
||||
async () => {
|
||||
const fetchMock = mockLargeLibraryApi();
|
||||
// This is a smoke check that the compiled query reaches the wire intact for ONE special
|
||||
// character — it does not need to be exhaustive, because the exhaustive per-character case
|
||||
// (every character in LIBRARY_PICKER_LUCENE_SPECIALS) already lives in
|
||||
// web/src/api/libraryBrowse.test.ts, against titleContainsQuery directly. Picking element 0
|
||||
// here is an arbitrary representative, not a hand-copied "every special" sample (#685 review
|
||||
// finding 6).
|
||||
const special = LIBRARY_PICKER_LUCENE_SPECIALS[0];
|
||||
const typed = `ro${special}ck`;
|
||||
|
||||
const dialog = await openAddItemsDialog();
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||
target: { value: typed }
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
|
||||
|
||||
await within(dialog).findByText('Movie Item 1');
|
||||
|
||||
const browseCall = browseCallsOf(fetchMock)[0];
|
||||
const params = new URL(String(browseCall?.[0]), 'http://localhost').searchParams;
|
||||
const sentQuery = params.get('query');
|
||||
|
||||
expect(sentQuery).toBe(titleContainsQuery(typed));
|
||||
// A raw, unescaped forward would still contain the bare special character; the compiled
|
||||
// form never does (it is backslash-escaped instead).
|
||||
expect(sentQuery).not.toBe(`title:*${typed}*`);
|
||||
expect(sentQuery).toContain(`\\${special}`);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'renders every merged row across all kinds — no post-fetch drop',
|
||||
{ timeout: 15000 },
|
||||
async () => {
|
||||
mockLargeLibraryApi(LIBRARY_PICKER_RESULTS);
|
||||
|
||||
const dialog = await openAddItemsDialog();
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||
target: { value: 'za' }
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
|
||||
|
||||
await within(dialog).findByText('Movie Item 1');
|
||||
|
||||
// Each of the 3 default-fan-out kinds returns a full LIBRARY_PICKER_RESULTS-row page;
|
||||
// nothing fetched should be discarded, so all of them must render.
|
||||
const titleEls = dialog.querySelectorAll('.ctv-collections-picker-row-title');
|
||||
expect(titleEls.length).toBe(3 * LIBRARY_PICKER_RESULTS);
|
||||
}
|
||||
);
|
||||
|
||||
/* ---------- reorder mode (#211) ---------- */
|
||||
|
||||
function reorderPages(id: number) {
|
||||
|
||||
@@ -35,10 +35,11 @@ import {
|
||||
getCollectionItems,
|
||||
getCollectionItemsWithMeta,
|
||||
getCollections,
|
||||
getLibraryBrowseItems,
|
||||
getSmartCollections,
|
||||
LIBRARY_PICKER_MIN_QUERY,
|
||||
messageFromCollectionError,
|
||||
removeItemFromCollection,
|
||||
searchLibraryBrowseItems,
|
||||
toAddItemsRequest,
|
||||
updateCollection,
|
||||
updateCollectionCustomOrder,
|
||||
@@ -52,8 +53,13 @@ import { SmartCollectionDialog } from '../builder/SmartCollectionDialog';
|
||||
|
||||
type Tab = 'manual' | 'smart';
|
||||
|
||||
// Every kind that can be added to a manual collection from the picker.
|
||||
const ADDABLE_TYPE_LIST: LibraryBrowseItem['mediaType'][] = [
|
||||
// Every kind that can be added to a manual collection from the picker. `as const satisfies`
|
||||
// keeps this a literal-string tuple (rather than widening to the whole `mediaType` union) while
|
||||
// still checking each entry against it, so `MediaKindFilter` below can be DERIVED from this list:
|
||||
// adding a non-addable kind to `MEDIA_KIND_FILTERS` becomes a compile error instead of a
|
||||
// convention nothing enforces (#685 review — the sum-of-totalCount hint is only correct because
|
||||
// every filterable kind is addable, and this makes that structural rather than a comment).
|
||||
const ADDABLE_TYPE_LIST = [
|
||||
'Movie',
|
||||
'TelevisionShow',
|
||||
'TelevisionSeason',
|
||||
@@ -64,15 +70,26 @@ const ADDABLE_TYPE_LIST: LibraryBrowseItem['mediaType'][] = [
|
||||
'OtherVideo',
|
||||
'Image',
|
||||
'RemoteStream'
|
||||
];
|
||||
] as const satisfies readonly LibraryBrowseItem['mediaType'][];
|
||||
const ADDABLE_TYPES = new Set<LibraryBrowseItem['mediaType']>(ADDABLE_TYPE_LIST);
|
||||
|
||||
// The default fan-out excludes seasons so a multi-season show doesn't flood the
|
||||
// results with per-season rows (issue #180); seasons (and the other narrower kinds
|
||||
// added for #211) stay reachable via the explicit media-kind filter below.
|
||||
const DEFAULT_SEARCH_KINDS: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'Artist'];
|
||||
// Derived from ADDABLE_TYPE_LIST for the same reason MediaKindFilter is: this is the SECOND
|
||||
// ingress into `kinds` (the `all` branch of runSearch), and the truncation hint sums pre-filter
|
||||
// totalCounts against post-ADDABLE_TYPES rows. A non-addable kind here would overstate the hint
|
||||
// with every one of its rows dropped, and typing it as the whole mediaType union let that through
|
||||
// (#685 review round 3 — one of two ingress paths was enforced, which is not an invariant).
|
||||
const DEFAULT_SEARCH_KINDS: AddableKind[] = ['Movie', 'TelevisionShow', 'Artist'];
|
||||
|
||||
type MediaKindFilter = 'all' | LibraryBrowseItem['mediaType'];
|
||||
// The single derivation every ingress into the searched kinds goes through. Named rather than
|
||||
// spelled inline at each use so the "one list, all ingresses" property is visible at a glance —
|
||||
// a fourth ingress is most likely to be written by copying one of the existing three (#685
|
||||
// review round 4).
|
||||
type AddableKind = (typeof ADDABLE_TYPE_LIST)[number];
|
||||
|
||||
type MediaKindFilter = 'all' | AddableKind;
|
||||
|
||||
const MEDIA_KIND_FILTERS: { label: string; value: MediaKindFilter }[] = [
|
||||
{ label: 'All', value: 'all' },
|
||||
@@ -217,34 +234,64 @@ function AddItemsDialog({
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<LibraryBrowseItem[]>([]);
|
||||
const [totalMatches, setTotalMatches] = useState(0);
|
||||
// The query a *settled* search actually ran for — never the live input. Rendered as a heading
|
||||
// over the results so a submitted-then-cleared input, or a selection made under an earlier
|
||||
// query, is never shown without saying which query produced it (#685 review). Reset to '' when
|
||||
// a search settles below LIBRARY_PICKER_MIN_QUERY (nothing was actually searched) or errors.
|
||||
const [submittedQuery, setSubmittedQuery] = useState('');
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [selected, setSelected] = useState<Map<string, LibraryBrowseItem>>(() => new Map());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [kindFilter, setKindFilter] = useState<MediaKindFilter>('all');
|
||||
|
||||
// `searchLibraryBrowseItems` owns the min-query gate, the pageSize clamp, and the
|
||||
// raw-Lucene-vs-compiled-query choice (§3b: "the bound belongs to the helper, not the caller").
|
||||
// This screen used to keep a SECOND copy of the min-query check here — the #685 review proved
|
||||
// the two masked each other (deleting either one left the whole suite green, so the boundary
|
||||
// test pinned nothing) — so there is now exactly one gate, in the helper, and every caller
|
||||
// (blank form submit, kind-chip click) routes through it unconditionally.
|
||||
const runSearch = async (filter: MediaKindFilter = kindFilter) => {
|
||||
const trimmed = query.trim();
|
||||
|
||||
setSearching(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const trimmed = query.trim();
|
||||
const kinds = filter === 'all' ? DEFAULT_SEARCH_KINDS : [filter];
|
||||
const perKind = await Promise.all(
|
||||
kinds.map((mediaType) => getLibraryBrowseItems({ pageSize: 50, query: trimmed, mediaType }))
|
||||
);
|
||||
const perKind = await Promise.all(kinds.map((mediaType) => searchLibraryBrowseItems(mediaType, trimmed)));
|
||||
const merged = perKind
|
||||
.flatMap((result) => result.page ?? [])
|
||||
.flatMap((result) => result.items)
|
||||
.filter((item) => ADDABLE_TYPES.has(item.mediaType))
|
||||
.sort((left, right) => left.title.localeCompare(right.title, undefined, { sensitivity: 'base' }));
|
||||
setResults(merged.slice(0, 50));
|
||||
// No slice: each kind is already bounded to LIBRARY_PICKER_RESULTS server-side by the helper,
|
||||
// so nothing fetched is discarded here (#685 — merged.slice(0, 50) used to drop up to 100 of
|
||||
// 150 rows). The per-kind cap can still truncate the real match count, though — surfaced
|
||||
// below via totalMatches (#685 review finding 4).
|
||||
setResults(merged);
|
||||
setTotalMatches(perKind.reduce((sum, result) => sum + result.totalCount, 0));
|
||||
// Below LIBRARY_PICKER_MIN_QUERY, the helper's own gate resolves every kind to
|
||||
// { items: [], totalCount: 0 } without issuing a request — so `merged`/`totalMatches` above
|
||||
// are already empty in that case, but `submittedQuery` must say "nothing was actually
|
||||
// searched" rather than name the sub-gate text.
|
||||
setSubmittedQuery(trimmed.length < LIBRARY_PICKER_MIN_QUERY ? '' : trimmed);
|
||||
} catch (searchError) {
|
||||
setError(messageFromCollectionError(searchError, 'Unable to search library'));
|
||||
// A failed search must not leave a stale results/totalMatches pair rendering a confident
|
||||
// "Showing N of M matches" hint beside the error banner (#685 review).
|
||||
setResults([]);
|
||||
setTotalMatches(0);
|
||||
setSubmittedQuery('');
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
// A kind-chip click is the second path into runSearch (a blank form submit is the other), and it
|
||||
// is deliberately NOT gated here: runSearch is the single sink and owns the min-query gate for
|
||||
// every caller via searchLibraryBrowseItems. §3b is explicit that an unreachable guard is an
|
||||
// untested one — the next path added would be the one nobody remembers to gate.
|
||||
const selectKindFilter = (filter: MediaKindFilter) => {
|
||||
setKindFilter(filter);
|
||||
void runSearch(filter);
|
||||
@@ -341,13 +388,44 @@ function AddItemsDialog({
|
||||
“All” searches movies, shows and artists; pick a specific kind to add seasons, episodes,
|
||||
music videos, songs, other videos, images or remote streams.
|
||||
</p>
|
||||
{/* Conditionally mounted, unlike the aria-live hint below — deliberately, not by oversight:
|
||||
`role="alert"` is the one live-region role screen readers reliably announce on INSERTION,
|
||||
so mounting it together with its content is right here and would be wrong there (#685
|
||||
review round 4 flagged the divergence as unexplained). */}
|
||||
{error && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
{submittedQuery !== '' && (
|
||||
// Names the query the CURRENT results (or the empty-results/"no matches" state) belong
|
||||
// to — never the live input, so a submitted-then-cleared query, or a selection made under
|
||||
// an earlier query, is never shown without saying which search produced it (#685 review).
|
||||
<p className="ctv-collections-picker-note">
|
||||
Results for “{submittedQuery}”
|
||||
</p>
|
||||
)}
|
||||
{/* The live region is mounted UNCONDITIONALLY with the condition inside it: most screen
|
||||
readers only announce mutations to a region that already existed, so creating the region
|
||||
and its text in the same commit announces nothing (#685 review round 3). */}
|
||||
<p aria-live="polite" className="ctv-collections-picker-note">
|
||||
{totalMatches > results.length ? `Showing ${results.length} of ${totalMatches} matches — narrow your search.` : ''}
|
||||
</p>
|
||||
<div className="ctv-collections-picker-results">
|
||||
{results.length === 0 && !searching ? (
|
||||
{results.length === 0 && submittedQuery === '' && !error ? (
|
||||
// Keyed to submittedQuery, not the live input: without it, backspacing the query back
|
||||
// below the min-query length after a successful search wiped the rendered rows AND their
|
||||
// checkmarks while `selected` (and the Add button's count) still held them (#685 review
|
||||
// finding 1) — and, separately, submitting a blank/cleared query after a real selection
|
||||
// must still say the selection persists rather than rendering as if nothing was ever
|
||||
// searched (#685 second review).
|
||||
<div className="ctv-collections-picker-empty">
|
||||
Type at least {LIBRARY_PICKER_MIN_QUERY} characters to search.
|
||||
</div>
|
||||
) : results.length === 0 && !searching && !error ? (
|
||||
// Also suppressed on `error`: "No results" asserts a search that COMPLETED and found
|
||||
// nothing, which is false when the request failed. The role="alert" banner above is the
|
||||
// whole message in that state (#685 review round 4).
|
||||
<div className="ctv-collections-picker-empty">No results — try a search above.</div>
|
||||
) : (
|
||||
results.map((item) => {
|
||||
|
||||
Reference in New Issue
Block a user