Files
ersatztv/scripts/tests/test_ci_image_pin_population.py
T
timothy 5ba442c11c fix(772,792): name the missing toolchain image, and stop a refusal leaving a verdict comment
#772 — the pinned CI toolchain image can be deleted out from under us, and when it was
(2026-08-11..13) all five `container:` jobs died at image pull, both required contexts
included, with the cause buried in each job's log. Root cause is registry-side and is now
established rather than guessed: an owner-level Gitea package cleanup rule (keep_count 15,
remove_days 1, remove_pattern `.*`, keep_pattern no 7-hex sha can match) deletes a sha tag
once 15 newer versions exist, and `ExecuteCleanupRules` ran nightly through the window. The
`ersatztv` package carries the same rule's fingerprint exactly — every sha tag older than
the 15-slot window is gone, every keep_pattern tag back to 26.3.1 survives. Version deletes
leave no audit row, so the specific run cannot be replayed; that limit is stated where the
claim is made. The durable fix belongs to the registry's repo: server-management#842.

What lands here is what a consumer of someone else's registry can do:

  * `toolchain-preflight`, a container-free job (a job consuming the image could not run to
    report it missing) resolving every pin against the registry and failing with a message
    that names the tag and the recovery. Not a `needs:` of the jobs it diagnoses — gating
    five jobs behind a checkout and one curl taxes every green run to speed up a rare red
    one, and they already fail fast.
  * Only HTTP 404 means gone. Everything else is could-not-tell, and rejected credentials
    fail rather than pass as unknown — "the check could not run" must never present as
    "the pin is fine".
  * A recovery path that does not need CI: rebuild the SAME tag from the commit it names
    and push it. The push half was verified against this registry on 2026-08-22 with a
    throwaway package (created, resolved 200, deleted).

#792 — the reported defect was the exit code, and re-measuring says that premise is false:
every no-status path already exits 1, and eight refusal modes now assert it against the real
predecessor, where they pass. The observed 0 came from the invocation, not the script. What
WAS broken is the half-state the issue describes second: the comment was written before the
status, so every refusal left `Review-verdict: MERGEABLE @ <head>` on a PR with no gating
status behind it. The two writes are now ordered status-then-comment, which makes the only
reachable half-state the safe one — a status with no comment leaves the merge hook's
condition (c) with nothing to classify, which is an `ask`. The refusals themselves are
untouched. Ordering rather than compensating deletion: an orphaned-comment cleanup needs a
Gitea call, and these refusals are usually caused by Gitea being unreachable.

Proof for the ordering is the split against origin/main's script: the 8 orphan/ordering
tests go red there, the 8 exit-code tests stay green.

fixes #772
fixes #792
Refs: server-management#842
Decisions-Edit: yes
2026-08-22 22:52:56 +02:00

354 lines
18 KiB
Python

"""The CI-image pin guard must see a container job that carries NO pin (ersatztv#774).
WHAT THIS IS PROTECTING. `pr-checks.yml`'s `ci-image-pin` job states the invariant in its own error
text — "Every container: job must pin ersatztv-ci:<7-char-sha>" — and then does not check it. What
it checks is:
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml \
| cut -d: -f2 | sort -u)
[ "${#pins[@]}" -eq 1 ]
`sort -u` collapses to DISTINCT VALUES, so the count is a property of the pins that are PRESENT. A
job that carries a `container:` block with no `ersatztv-ci:` pin — or no `container:` block at all —
contributes nothing to grep's output, so it cannot move that count. Delete the `container:` block
from `test` and four pins remain: still one distinct value, still green, and a REQUIRED context now
runs on the bare runner instead of the toolchain image. That is ersatztv#774's Family A exactly: a
guard that cannot see the member that is MISSING, because its population is the set of matches
rather than the set of jobs.
THE SPLIT WITH THE SHELL GUARD IS DELIBERATE, and is not two copies of one rule (which would be
#773's Family C). Two different assertions over the same subject:
* `ci-image-pin` (shell, pr-checks.yml) owns the questions that need GIT HISTORY — does the pin
resolve to a commit, is it exactly 7 chars, is it the last commit to touch `docker/ci`. A
pytest cannot answer those without a full clone.
* this file owns the question that needs the PARSED YAML — is the set of jobs declaring a
`container:` exactly the set of jobs pinning the image. A shell grep structurally cannot answer
that, which is why it was never asked.
Neither restates the other, and each says so above the code.
"""
from __future__ import annotations
import copy
import re
from pathlib import Path
import pytest
import yaml
from scripts.tests.tracked_files import tracked_paths
REPO_ROOT = Path(__file__).resolve().parents[2]
WORKFLOWS_DIR = REPO_ROOT / ".gitea" / "workflows"
WORKFLOW = WORKFLOWS_DIR / "docker-build.yml"
# Resolved against the GIT INDEX rather than `Path.glob` (ersatztv#806), and `*.yaml` alongside
# `*.yml`: Gitea accepts both spellings, so a `.yaml` workflow was structurally invisible to the
# scope check below while reading as covered.
WORKFLOWS = (".gitea/workflows", ("*.yml", "*.yaml"))
def workflow_files() -> list[Path]:
"""THE WORKFLOW POPULATION, from the git index. Named rather than inline so the shared proof in
`test_guard_populations_derive_from_git.py` can assert it never admits an untracked file."""
return tracked_paths(*WORKFLOWS)
# The image repository, without the tag. Matched as a whole path rather than by the bare
# `ersatztv-ci` token so a job pointing at a LOOK-ALIKE registry (a personal fork, a typo'd host)
# is a fault rather than a silent pass — the shell guard's `grep -oE 'ersatztv-ci:[0-9a-f]+'` reads
# the tail of any string and would accept `evil.example/ersatztv-ci:32747a0`.
IMAGE_REPO = "192.168.1.95:3000/timothy/ersatztv-ci"
_PIN = re.compile(rf"^{re.escape(IMAGE_REPO)}:([0-9a-f]+)$")
_DOC = yaml.safe_load(WORKFLOW.read_text())
# THE HAND-REVIEWED REGISTRY of jobs that must run inside the CI toolchain image, cross-checked in
# BOTH directions against what the workflow actually declares. This is deliberately a literal, and
# the reason is the one case set equality between two DERIVED sets cannot cover.
#
# The first version of this file compared `container_jobs(doc)` against `pinned_jobs(doc)` and
# nothing else. That is blind to the mutation that matters most: delete a `container:` block and the
# job leaves BOTH sets together, so the comparison stays balanced and reports green — which is the
# very fail-open this file was written to close, reproduced one level up. A guard whose expected set
# shrinks in step with the thing it is guarding is not a guard.
#
# So the population needs one anchor that does NOT move when the workflow does, and a reviewed list
# is the only available one: nothing else in the repo records which jobs are supposed to need the
# toolchain. This is the same shape as `web/src/api/pageSizeCallSites.guard.test.ts` — discovery on
# one side, a reviewed registry on the other, compared both ways — and it is the SCOPE half of
# `testing.guard-derives-population-from-source`, not an exception to it. Editing this set is a
# reviewable act; a job silently losing its container block is not.
TOOLCHAIN_JOBS = frozenset({"test", "migrations", "functional-e2e", "api-docs", "format"})
# `scan`, `build` and `toolchain-preflight` deliberately run on the bare runner: `scan` is
# `runs-on: small` and needs only python, `build` drives docker/buildx on the host, and
# `toolchain-preflight` exists to report that the pinned toolchain image is GONE — a job that
# consumed that image could not run to say so (ersatztv#772). Listed here so their ABSENCE above
# reads as a decision rather than an oversight.
BARE_RUNNER_JOBS = frozenset({"scan", "build", "toolchain-preflight"})
def _jobs(doc) -> dict:
return doc["jobs"]
def container_jobs(doc) -> set[str]:
"""Every job declaring a `container:`. THE AUTHORITATIVE POPULATION.
Derived from the parsed workflow, which is the only thing that knows the whole of it. A literal
list here would reintroduce the defect one file over — correct on the day it was written and
unable to report the day a sixth job appeared.
"""
return {name for name, job in _jobs(doc).items() if isinstance(job, dict) and "container" in job}
def pinned_jobs(doc) -> dict[str, str]:
"""job -> pinned tag, for every job whose container image is the CI toolchain image."""
out = {}
for name, job in _jobs(doc).items():
if not isinstance(job, dict):
continue
image = str((job.get("container") or {}).get("image", ""))
m = _PIN.match(image)
if m:
out[name] = m.group(1)
return out
def pin_population_faults(doc) -> list[str]:
"""Set equality in BOTH directions, plus tag agreement. Accumulated, never fail-fast.
Both directions are reported separately because they are different defects. UNPINNED (a
container job the guard cannot see) is the fail-open this file exists for. PINNED-BUT-NOT-A-
CONTAINER-JOB cannot arise from `pinned_jobs` as written, but is computed anyway so that a
future change to either helper cannot quietly make the comparison one-sided.
"""
declared = container_jobs(doc)
pinned = pinned_jobs(doc)
faults = []
# AGAINST THE REGISTRY FIRST. This is the direction the two derived sets cannot cover: a job
# that loses its `container:` block leaves `declared` and `pinned` together, so their equality
# survives untouched while the job quietly moves to the bare runner.
for name in sorted(TOOLCHAIN_JOBS - set(pinned)):
faults.append(
f"job '{name}' is registered as needing the CI toolchain image but does not pin it "
"(its container: block is missing or points elsewhere) — it is running on the bare "
"runner"
)
for name in sorted(set(pinned) - TOOLCHAIN_JOBS):
faults.append(f"job '{name}' pins the toolchain image but is not in TOOLCHAIN_JOBS")
for name in sorted(declared - set(pinned)):
image = str((_jobs(doc)[name].get("container") or {}).get("image", ""))
faults.append(
f"job '{name}' declares a container: but its image is {image!r}, not "
f"{IMAGE_REPO}:<sha>. ci-image-pin's grep cannot see this job at all, so the pin it "
"reports as current says nothing about what this job actually runs in."
)
for name in sorted(set(pinned) - declared):
faults.append(f"job '{name}' pins the image without declaring a container: block")
tags = set(pinned.values())
if len(tags) > 1:
faults.append(
f"jobs pin DIFFERENT tags: {sorted((n, t) for n, t in pinned.items())}. All container "
"jobs must run the same toolchain image."
)
return faults
# ------------------------------------------------------------------------------------------------
# THE LIVE ASSERTION
# ------------------------------------------------------------------------------------------------
def test_every_container_job_pins_the_CI_toolchain_image():
faults = pin_population_faults(_DOC)
assert not faults, (
"docker-build.yml has a container: job the CI-image pin guard cannot see:\n "
+ "\n ".join(faults)
+ "\n\n`ci-image-pin` counts DISTINCT pin strings, so a job with no pin contributes nothing "
"to that count and passes silently while running on the bare runner. See ersatztv#774."
)
def test_the_registry_and_the_workflow_agree_on_which_jobs_use_the_toolchain():
"""BOTH directions against the reviewed registry — the anchor that does not move.
Left-to-right catches a job silently LOSING its container block (the mutation set equality
between two derived sets cannot see, because both sides shrink together). Right-to-left catches
a NEW container job nobody registered. Neither direction is optional and the messages differ,
because the two are opposite mistakes.
"""
declared = container_jobs(_DOC)
assert TOOLCHAIN_JOBS - declared == frozenset(), (
f"these jobs are registered as needing the CI toolchain image but no longer declare a "
f"container: block — {sorted(TOOLCHAIN_JOBS - declared)}. They are now running on the bare "
"runner. If that is deliberate, move them to BARE_RUNNER_JOBS in this file and say why in "
"the PR."
)
assert declared - TOOLCHAIN_JOBS == frozenset(), (
f"these jobs declare a container: but are not in TOOLCHAIN_JOBS — "
f"{sorted(declared - TOOLCHAIN_JOBS)}. Add them to the registry so the pin guard covers "
"them, or they will run on an image nothing checks."
)
def test_the_registry_partitions_every_job_in_the_workflow():
"""No job may be in neither list. ANTI-VACUITY with teeth, and the reason it is here:
a floor like `len(container_jobs) >= 3` would be satisfied by a broken parse that happened to
find four jobs, and would say nothing about a NEW job appearing in a third state nobody
considered. Partitioning the whole `jobs:` map means every job is a decision someone recorded.
"""
all_jobs = set(_jobs(_DOC))
unclassified = all_jobs - TOOLCHAIN_JOBS - BARE_RUNNER_JOBS
assert not unclassified, (
f"docker-build.yml has job(s) {sorted(unclassified)} that are in neither TOOLCHAIN_JOBS nor "
"BARE_RUNNER_JOBS. Every job must be one or the other, so that 'runs on the bare runner' is "
"always a recorded decision rather than an omission."
)
assert not (TOOLCHAIN_JOBS & BARE_RUNNER_JOBS), "a job cannot be in both lists"
assert all_jobs == TOOLCHAIN_JOBS | BARE_RUNNER_JOBS, (
f"the registry names jobs that do not exist: {sorted((TOOLCHAIN_JOBS | BARE_RUNNER_JOBS) - all_jobs)}"
)
# ------------------------------------------------------------------------------------------------
# MUTATION PROOFS — disarm the invariant one way at a time, each must be DETECTED (ersatztv#775)
# ------------------------------------------------------------------------------------------------
def _mutants():
"""(id, mutated doc) for each single-job way the invariant can be broken.
Every container job in turn, not a sample: the interesting drop is whichever job someone
actually edits, and proving detection on only the first would prove the case least likely to
happen (ersatztv#773 §3 Family A, applied to this file's own tests).
"""
for job in sorted(container_jobs(_DOC)):
dropped = copy.deepcopy(_DOC)
del dropped["jobs"][job]["container"]
yield f"{job}-container-removed", dropped
unpinned = copy.deepcopy(_DOC)
unpinned["jobs"][job]["container"]["image"] = "mcr.microsoft.com/dotnet/sdk:10.0"
yield f"{job}-image-swapped", unpinned
lookalike = copy.deepcopy(_DOC)
lookalike["jobs"][job]["container"]["image"] = "evil.example/timothy/ersatztv-ci:32747a0"
yield f"{job}-lookalike-registry", lookalike
skewed = copy.deepcopy(_DOC)
skewed["jobs"][job]["container"]["image"] = f"{IMAGE_REPO}:deadbee"
yield f"{job}-tag-skewed", skewed
_MUTANTS = list(_mutants())
@pytest.mark.parametrize("doc", [m for _, m in _MUTANTS], ids=[i for i, _ in _MUTANTS])
def test_a_single_job_losing_its_pin_is_DETECTED(doc):
"""The proof this guard can go red. Without it, `pin_population_faults` returning a constant
empty list would satisfy the live assertion above and prove nothing — which is how #621 and
#685 both shipped."""
assert pin_population_faults(doc), (
"the population check accepted a workflow in which a container job no longer runs the pinned toolchain image"
)
def test_the_mutation_set_is_not_empty():
"""The positive control for the parametrisation itself.
If `container_jobs` ever returned an empty set, `_mutants()` would yield nothing, pytest would
collect zero cases from the decorator above, and the file would report all-green having proved
nothing. That is the vacuous-by-sampling shape this whole issue is about, and it is reachable
here through a single broken helper.
"""
expected = 4 * len(TOOLCHAIN_JOBS)
assert len(_MUTANTS) == expected, (
f"expected 4 mutations per registered job ({expected}), got {len(_MUTANTS)}. A floor rather "
"than an equality here would let a `container_jobs()` that degraded to 3 of 5 jobs pass "
"while silently testing less — the message would still claim 4 per job."
)
def test_docker_build_is_the_ONLY_workflow_pinning_the_toolchain_image():
"""This file reads ONE workflow, which is itself a scope mirror needing its own check.
`WORKFLOW` hardcodes `docker-build.yml`, and the implicit claim — that no other workflow uses
the toolchain image — mirrors a machine-readable source (the tracked `.gitea/workflows/*.y*ml`)
that nothing consulted. `renovate.yml` already declares a `container:` with a different image,
so the shape is live. A future workflow adopting `ersatztv-ci:` would acquire no pin-population
guard, no single-tag check and no partition, silently, while `pin_population_faults`'s own error
text claims "All container jobs must run the same toolchain image".
Found by cold review, which correctly noted this file criticises `MARKED_JOBS` for exactly this
and then shipped the same shape without even the dated comment `MARKED_JOBS` carries.
The population comes from the GIT INDEX (ersatztv#806). A `Path.glob` here answered a question
about the machine rather than about the repo: an untracked scratch workflow left in
`.gitea/workflows/` would be parsed and could redden this test on one checkout while CI, which
never sees it, stayed green. The pattern set gained `*.yaml` in the same change — Gitea accepts
both spellings, so a `.yaml` workflow adopting the toolchain image was invisible here while this
test read as covering every workflow.
Checked by PARSING each workflow's `container.image`, not by grepping the file. A text search
reports `ci-image.yml`, which names the image because it BUILDS and PUSHES it — a producer, not
a consumer. Grepping would have made this test permanently red on a correct tree, which is the
fastest route to a correct guard being deleted.
"""
others = []
for p in workflow_files():
if p.name == WORKFLOW.name:
continue
doc = yaml.safe_load(p.read_text()) or {}
for name, job in (doc.get("jobs") or {}).items():
if not isinstance(job, dict):
continue
image = str((job.get("container") or {}).get("image", ""))
# Keyed on the IMAGE REPOSITORY, not on `_PIN`'s literal-tag match. A job written as
# `image: <repo>:${{ matrix.tag }}` runs on the toolchain image but fails `_PIN`, so
# keying on the pin would have let a templated tag slip the whole check — found by cold
# review, which constructed exactly that. The tag being an expression is itself a fault
# (nothing could then verify WHICH image ran), so this reports the job either way.
if image.startswith(f"{IMAGE_REPO}:"):
others.append(f"{p.name}:{name}")
assert not others, (
f"{sorted(others)} run container jobs on the CI toolchain image, but this file only checks "
f"{WORKFLOW.name}, so they have no pin-population guard at all. Extend the check to cover "
"them rather than leaving the coverage implied."
)
def test_the_shell_guards_grep_sees_the_same_tags_the_jobs_run():
"""Ties the two halves together, so they cannot drift into disagreeing about the subject.
`ci-image-pin` reads the file with a grep for `ersatztv-ci:<hex>`. This compares what that grep
sees against what the parsed jobs actually run.
DISTINCT VALUES rather than a count, deliberately. The counts legitimately differ: the file's
header comment at docker-build.yml:32 documents the pin in prose, so the shell guard's grep
reads SIX strings where the YAML has five pinned jobs. Asserting on the count would either fail
today or have to hardcode "+1 for the comment", which breaks the moment a second comment
mentions the pin.
What actually has to hold for the shell guard's verdict to be sound is that its `sort -u` set
equals the set of tags the jobs really run. Comparing the distinct sets says exactly that — and
as a free side effect it makes the header comment SELF-CHECKING: bump the five image lines and
forget the comment, and the sets diverge here with a message naming both, instead of the shell
guard reporting "pins MORE THAN ONE ersatztv-ci tag" and pointing at prose.
"""
text = WORKFLOW.read_text()
grepped = {m for m in re.findall(r"ersatztv-ci:([0-9a-f]+)", text)}
parsed = set(pinned_jobs(_DOC).values())
assert grepped == parsed, (
f"ci-image-pin's grep sees the distinct tags {sorted(grepped)} but the parsed container "
f"jobs run {sorted(parsed)}. A tag mentioned in the file but not run by any job (a stale "
"header comment) makes the shell guard's 'MORE THAN ONE pin' check fire on prose; a tag "
"run but not greppable means the shell guard is not checking that job at all."
)