Files
ersatztv/scripts/tests/test_ci_release_path_scan_job.py
T
timothyandtimothy b6b3520bdb
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 8s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 26s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m12s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m14s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m32s
fix(809,822): isolate the suite from the production hook-fire log by construction (#874)
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-29 02:32:26 +00:00

705 lines
37 KiB
Python

r"""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.
The detection ALGORITHM is not reimplemented here — it lives in `test_ci_dropped_step_guard.py`, and
this job runs that file. What this file holds is the WIRING that makes the ban fail-closed (the job
exists, `build` depends on it, nothing can skip it, its own steps cannot be silently dropped) plus
ONE end-to-end probe that executes the scan step's real command against a poisoned copy of the repo
and requires it to fail.
THAT PROBE IS THE LOAD-BEARING TEST, and it exists because shape assertions lost twice. Round 2
replaced a substring check with checks ABOUT the command — bare-token argument, no `-k`, no `||` —
and round 3 then disarmed the gate seven more ways that all left the suite green: `echo`ing the
command instead of running it, flags moved past a `\` continuation, a trailing `exit 0`,
`if false; then … fi`, `set +e`, and `PYTEST_ADDOPTS` in the step's `env:`. Two further disarms lived
in the sibling module where no shape check could ever reach — the ban test's parametrize list swapped
to `MARKED_JOBS`, and its opener regex neutered. Running the command settles every one of them,
because it asks the only question that matters: with a delimiter in `build`'s `Smoke` body, does this
command fail? The shape checks are kept as a faster, more specific signal, not as the guarantee.
ROUND 4 THEN FOUND THE TWIN OF THE ROUND-3 FIX, which is the reusable lesson here: fixing the STEP
`env:` tier did not generalise, and the same `PYTEST_ADDOPTS` disarm placed one tier up — on the JOB
— defeated the probe, because the probe reconstructed only the step's env. It now layers all three
tiers (workflow, job, step). The same round found that `needs:` is not by itself a gate: an
`always()` in `build`'s `if:` downgrades the edge to mere ordering, and the delimiter ban does not
cover `if:` expressions, so nothing else would have objected.
ENUMERATING THAT LIST THEN FOUND A FOURTH TIER the probe cannot ever reach: a step writing to
`$GITHUB_ENV` injects into LATER steps at runtime, so it is invisible to any static reconstruction of
the workflow text. Measured — `PYTEST_ADDOPTS` supplied that way makes the ban command exit 0 on a
poisoned tree while the probe reports healthy. That one is BANNED rather than modelled
(`test_no_step_in_the_scan_job_writes_to_GITHUB_ENV`), because emulating the runner's semantics would
be a second implementation of precisely the kind #767 already deleted once. The rule after any fix
here: enumerate the tiers and the twin, and where a tier cannot be observed, forbid it.
(Two disarms review reported were checked and are NOT real: `--ignore=` and a `conftest.py`
`collect_ignore` do not suppress a file pytest was given explicitly as an argument — measured, the
ban test still ran and still failed. Recorded so they are not re-litigated. But note what refuting
them did NOT establish: the WORKING attacks through that same configuration channel — a repo-root
`pytest.ini` `addopts`, or `pytest_collection_modifyitems` — were simply never tried, and both
disarm everything here. Refuting two variants of a channel is not clearing the channel.)
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 drop the pytest step AND the assert step. And the
probe runs the command, not the RUNNER: that a red `scan` actually skips `build` is a live
measurement recorded on the issue, which no test here can establish.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import uuid
from pathlib import Path
import pytest
import yaml
# Imports the shared index derivation to build a HERMETIC fixture copy, not to derive a guard
# population — see `_repo_copy`. Recorded as such in POPULATION_EXEMPT in
# `test_guard_populations_derive_from_git.py`; the exemption lives there, not here, because a marker
# a file grants itself is a kill switch any prose mention can trip.
from scripts.tests import hook_fire_isolation, tracked_files
# Both spellings, matching what the converted guards consider the workflow set.
WORKFLOWS = (".gitea/workflows", ("*.yml", "*.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_build_does_not_NEUTRALISE_the_edge_with_an_always_style_condition():
"""`needs:` alone is not the gate — `always()` downgrades it to mere ordering.
A dependent whose `if:` is `always()` (or `!cancelled()`, or `failure()`) runs even when the job
it needs went red, so `build` would publish with a red `scan`. Reproduced by review:
`if: ${{ always() && github.event_name != 'pull_request' }}` left the whole suite green while the
edge no longer gated anything. The delimiter ban does not cover `if:` expressions, so nothing
else in this repo would have objected either.
"""
cond = str(_DOC["jobs"]["build"].get("if", ""))
for escape in ("always(", "!cancelled(", "cancelled(", "failure("):
assert escape not in cond.replace(" ", ""), (
f"`build`'s `if:` contains {escape!r}, which makes it run despite a red `{JOB}` — the "
f"`needs:` edge stops being a gate: {cond!r}"
)
def test_the_scan_job_runs_the_out_of_pytest_positive_control():
"""The proof that the ban would DETECT must be invoked, and must not be a test.
Everything else in this file lives inside pytest, and pytest's own configuration outranks it: a
repo-root `pytest.ini` with `addopts = -k "not delimiter_banned"`, or a `conftest.py` with
`pytest_collection_modifyitems`, deselects the ban test AND every test guarding it, leaving the
suite green with a delimiter in `Smoke`. Measured, both forms. `scripts/ci-prove-ban-detects.sh`
is the answer precisely because it is a shell step: it cannot be deselected.
This test can itself be deselected, of course — which is why it is not what makes the gate safe.
It is here so that REMOVING the step from the workflow is loud in review; the step's own presence
in the job, plus its `selfcheck` marker in the trailing assert, is what makes it loud at runtime.
"""
script = "scripts/ci-prove-ban-detects.sh"
assert (REPO_ROOT / script).is_file(), f"{script} is missing"
assert os.access(REPO_ROOT / script, os.X_OK), f"{script} is not executable"
assert any(script in s["run"] for s in _run_steps()), (
f"no step in `{JOB}` runs {script} — the ban is asserted to HOLD but never proved to DETECT"
)
assert "selfcheck" in {k for _, k in _marked()}, (
"the positive-control step records no marker, so a dropped one would go unnoticed"
)
def test_no_step_in_the_scan_job_writes_to_GITHUB_ENV():
"""The one env tier the probe structurally CANNOT reconstruct, so it is banned instead.
`_scan_body_and_env` layers the workflow, job and step `env:` blocks — all of which are visible
in the workflow text. A step writing `FOO=bar >> $GITHUB_ENV` injects into LATER steps at
runtime, which no static reconstruction can see. Measured: with `PYTEST_ADDOPTS` supplied that
way, the ban command exits 0 on a tree whose `Smoke` body is poisoned — a full disarm that the
execution probe reports as healthy, because the probe's subprocess never receives it.
Banning the mechanism is the fix rather than modelling it: nothing in this job needs to pass a
value to a later step, and a guard that has to emulate the runner's `GITHUB_ENV` semantics would
be a second implementation of exactly the kind that #767 already deleted once.
"""
offenders = [s.get("name") for s in _run_steps() if "GITHUB_ENV" in s["run"]]
assert not offenders, (
f"step(s) {offenders} in `{JOB}` write to GITHUB_ENV, which injects environment into later "
"steps at runtime where no static check — including this file's execution probe — can see "
"it. `PYTEST_ADDOPTS` supplied that way disarms the ban entirely."
)
def test_the_scan_job_is_not_advisory_at_JOB_level():
"""`continue-on-error` on the JOB reports success to dependents even when it failed.
The step-level check elsewhere in this file does not see this tier — the same one-tier-up miss
that let a job-level `env:` disarm the probe.
"""
assert not _job().get("continue-on-error"), (
f"`{JOB}` is job-level advisory — a failure would report success to `build`"
)
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 _pytest_line() -> str:
"""The ONE non-comment line that actually invokes pytest, from the job's real body.
A substring test over the whole body is not enough, and that is not hypothetical: review
disarmed the gate three ways that all left the suite green — commenting the invocation out and
echoing instead (the filename still appears, in the comment), appending `-k 'not
delimiter_banned'`, and appending `|| true`. Each left `scan` green with the ban unchecked and
`build` publishing. So this locates the executable line and the caller asserts its shape.
"""
lines = [
ln.strip()
for s in _run_steps()
for ln in s["run"].splitlines()
if "python3 -m pytest" in ln and not ln.strip().startswith("#")
]
assert len(lines) == 1, f"expected exactly 1 pytest invocation in `{JOB}`, found {len(lines)}"
return lines[0]
def test_the_scan_job_actually_invokes_the_ban_test():
"""Otherwise the job is an expensive no-op that reports green.
The path must appear as a BARE TOKEN on the pytest line — not merely somewhere in the body —
so that commenting the invocation out is a red. Renaming the ban test without updating the
workflow is a red here too, rather than a silently unguarded release path.
"""
assert (REPO_ROOT / BAN_TEST_FILE).is_file()
assert BAN_TEST_FILE in _pytest_line().split(), (
f"`{JOB}` does not pass {BAN_TEST_FILE} to pytest as an argument: {_pytest_line()!r}"
)
def _scan_body_and_env():
"""The ban step's REAL `run:` body, and the FULL env the runner would give it.
ALL THREE TIERS, lowest precedence first: workflow `env:`, job `env:`, step `env:`. Reconstructing
only the step tier is not a smaller version of this — it is a hole, and review reproduced it: a
`PYTEST_ADDOPTS: -k "not delimiter_banned"` placed on the JOB disarmed the real gate (exit 0 with
a poisoned `Smoke` body) while every test here stayed green, because the probe never saw that
tier. It is the exact twin of the step-level `PYTEST_ADDOPTS` disarm caught one round earlier —
which is the lesson: after fixing one tier, enumerate the others rather than assuming the fix
generalised.
"""
step = next(s for s in _run_steps() if "python3 -m pytest" in s["run"])
env = {}
for tier in (_DOC.get("env"), _job().get("env"), step.get("env")):
env.update({str(k): str(v) for k, v in (tier or {}).items()})
return step["run"], env
def _repo_copy(tmp_path: Path) -> Path:
"""A minimal executable copy of the repo: the tracked workflows plus the tracked `scripts/`.
ASSESSED FOR ersatztv#806. This is NOT a completeness guard — it is a fixture assembling a
harness, and no assertion in this file is about which files it found; the probes assert what the
scan command DOES to the copy. It takes its file LIST from the index anyway, for hermeticity
rather than completeness: `shutil.copytree` copied whatever was on disk, so untracked files and
`scripts/__pycache__` entered a tree whose behaviour the probes then measure.
WHAT THAT DOES AND DOES NOT BUY, stated exactly, because a fixture described as hermetic stops
being questioned. The list comes from the index; the CONTENT comes from the working tree, so an
unstaged edit to a tracked `scripts/**` file is still copied in. Making the content hermetic too
would need `git show`/`git archive` and would mean the probes stop testing the tree under edit,
which is the wrong trade for a test whose job is to catch a disarm in that tree.
An extra WORKFLOW in the copy is inert, but check what the step runs before relying on that:
`docker-build.yml:723` runs TWO files, `test_ci_dropped_step_guard.py` AND this one, and both
parse only `docker-build.yml`.
THE COPY IS NOT A GIT REPOSITORY, and that is the constraint to know before touching either file
that step runs. Neither may derive a population through `scripts/tests/tracked_files.py`:
`git ls-files` inside the copy fails, and the release-path scan step fails with it. The copying
happens HERE, in the real repo, which is why this fixture may use the index while its subjects
may not. #787 rewrote `test_ci_dropped_step_guard.py`'s scope to derive from
`.gitea/required-status-contexts.json` and reached for `tracked_paths` on the way, which broke
all three tests below until the index derivation came back out — so this paragraph earned its
keep, and it is what stands between the next such edit and a broken release gate.
"""
dst = tmp_path / "repo"
(dst / ".gitea" / "workflows").mkdir(parents=True)
for wf in tracked_files.tracked_paths(*WORKFLOWS):
shutil.copy2(wf, dst / ".gitea" / "workflows" / wf.name)
# `.gitea/` DATA the copied tests read, which is not a workflow and so is not in the loop above.
# `test_ci_dropped_step_guard.py` derives its marked-job scope from this snapshot (#787), and
# without it the scan step dies on a FileNotFoundError that says nothing about the delimiter ban
# this harness exists to prove. Named explicitly rather than copied by glob: a new `.gitea/` file
# should have to be considered, not silently swept into a hermetic fixture.
for rel in (".gitea/required-status-contexts.json",):
source = REPO_ROOT / rel
assert source.is_file(), (
f"{rel} is missing, so the harness copy would be incomplete and the probes below would "
"measure a tree that is missing part of the thing under test."
)
shutil.copy2(source, dst / rel)
for rel in tracked_files._git_ls_files():
if not rel.startswith("scripts/"):
continue
source = REPO_ROOT / rel
# Diagnosed, not raised as a bare FileNotFoundError one line after using the module whose
# whole point is reporting this case comprehensibly.
assert source.is_file(), (
f"git tracks {rel} but there is no file there, so the harness copy would be incomplete "
"and the probes below would measure a tree that is missing part of the thing under test."
)
target = dst / rel
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
return dst
# THE RECURSION FENCE. The scan step's real command runs this very file, whose two probe tests would
# each spawn the command again — unbounded recursion, discovered by running it. The fence lets the
# nested run skip just those two probes while still executing the ban test, which is the part that
# matters. Deliberately NOT a `-k`/`--ignore` on the nested command line: narrowing the command would
# mean the thing under test is no longer the command the workflow runs.
#
# IT IS A NONCE, NOT A CONSTANT. A constant fence is a one-line silent kill switch: exporting
# `ETV_SCAN_PROBE_NESTED=1` anywhere — a job `env:`, a runner default — would delete both
# load-bearing probes everywhere, with nothing going red, after which the disarms they exist to catch
# become unobservable again. So the parent writes a fresh value into the copied tree and passes the
# same value in the environment; the skip fires only when the two agree, which cannot happen outside
# a run this file itself spawned.
_FENCE = "ETV_SCAN_PROBE_NESTED"
_NONCE_FILE = ".scan-probe-nonce"
def _fence_active() -> bool:
value = os.environ.get(_FENCE)
if not value:
return False
try:
return (Path.cwd() / _NONCE_FILE).read_text().strip() == value
except OSError:
return False
_nested = pytest.mark.skipif(
_fence_active(),
reason="nested invocation of the scan step's own command — the outer run owns this probe",
)
def _run_the_real_scan_body(repo: Path, tmp_path: Path):
body, step_env = _scan_body_and_env()
env = {
"PATH": os.environ["PATH"],
"HOME": os.environ.get("HOME", str(tmp_path)),
# Built from scratch WITH the real HOME, so `scripts/hook-fire-log.sh` would otherwise
# resolve straight back to `$HOME/.cache/ersatztv/hook-fire` for everything this body runs
# — including the nested pytest. Carried explicitly because no `os.environ` spread does it
# here; this is the from-scratch route that `conftest.py`'s pre-collection isolation cannot
# reach, and the launch guard rejects it (ersatztv#809).
hook_fire_isolation.ENV_VAR: os.environ[hook_fire_isolation.ENV_VAR],
"GITHUB_WORKSPACE": str(repo),
"RUNNER_TEMP": str(tmp_path / "runner"),
"GITHUB_JOB": JOB,
"GITHUB_RUN_ID": "990099",
"GITHUB_RUN_ATTEMPT": "1",
}
env.update(step_env)
# The fence is applied AFTER the workflow's own env tiers, so the file under test cannot control
# it in either direction — it could otherwise turn the nested probes back on (recursion) or off.
nonce = uuid.uuid4().hex
(repo / _NONCE_FILE).write_text(nonce)
env[_FENCE] = nonce
(tmp_path / "runner").mkdir(exist_ok=True)
return subprocess.run(["bash", "-c", body], cwd=repo, env=env, capture_output=True, text=True)
@_nested
def test_the_scan_step_REALLY_FAILS_on_a_poisoned_workflow(tmp_path):
"""THE POSITIVE CONTROL, and the one test here that is not a shape assertion.
It executes the ban step's actual `run:` body — with its actual `env:` — against a copy of the
repo whose `build`/`Smoke` body carries an injected delimiter, and requires a non-zero exit.
This exists because shape checks lost. An earlier round asserted things ABOUT the command
(the ban test's path is a bare token, no `-k`/`--deselect`, no `||`) and review then disarmed the
gate anyway, every time leaving the suite green: `echo`ing the command instead of running it,
flags moved past a `\\` continuation, a trailing `exit 0`, `if false; then … fi`, `set +e`, and
`PYTEST_ADDOPTS` in the step's `env:`. Enumerating shapes of a command cannot win; running the
command settles them together, including the two in the sibling module that no shape check could
reach (a parametrize list swapped to `MARKED_JOBS`, and a neutered opener regex). No count is
given on purpose — an earlier draft's tally disagreed with its own list, and the number was never
the point.
The negative control below is what stops this passing for the wrong reason. And note the limit of
this probe: it runs the command in a COPY, so it does not inherit a repo-root pytest config —
that tier is covered by `scripts/ci-prove-ban-detects.sh`, which runs in the real checkout.
"""
repo = _repo_copy(tmp_path)
wf = repo / ".gitea" / "workflows" / "docker-build.yml"
text = wf.read_text()
anchor = ' IMG="${IMAGE}:${SMOKE_SHORT_SHA}"'
assert anchor in text, "anchor for the poison is gone — rewrite this control"
wf.write_text(text.replace(anchor, " # ${{ steps.meta.outputs.short }}\n" + anchor, 1))
res = _run_the_real_scan_body(repo, tmp_path)
assert res.returncode != 0, (
"the scan step PASSED on a workflow whose `build`/`Smoke` body contains an expression "
f"delimiter — the release-path gate is disarmed.\nstdout:\n{res.stdout}\nstderr:\n{res.stderr}"
)
@_nested
def test_the_scan_step_PASSES_on_the_clean_workflow(tmp_path):
"""The negative control for the test above.
Without it, a scan step that failed for ANY reason — a missing interpreter, a bad path, a
syntax error in the body — would satisfy the positive control while proving nothing about
detection. This also demonstrates the copied tree is a working harness rather than a broken one.
"""
res = _run_the_real_scan_body(_repo_copy(tmp_path), tmp_path)
assert res.returncode == 0, (
f"the scan step failed on a CLEAN workflow.\nstdout:\n{res.stdout}\nstderr:\n{res.stderr}"
)
@_nested
def test_the_PROOF_SCRIPT_itself_refuses_when_the_ban_is_deselected(tmp_path):
"""A positive control for the positive control — the one guard that had none.
Everything else here is guarded by something; `ci-prove-ban-detects.sh` was guarded only by a
presence-and-executable-bit check, so a plausible "simplify" edit (relaxing the exit-code test
back to "any non-zero means it noticed") would silently reinstate a full disarm with every test
green. That is the "a guard only exercised on the happy path proves nothing" failure this file
argues about every other guard.
The disarm reproduced here is the real one: a repo-root `conftest.py` that deselects the ban test
entirely, which makes pytest exit 5 (nothing collected) rather than fail — the exact reading that
made an earlier draft of the script report the gate healthy while an unsmoked candidate would
publish.
"""
repo = _repo_copy(tmp_path)
(repo / "conftest.py").write_text(
"def pytest_collection_modifyitems(config, items):\n"
" items[:] = [i for i in items if 'test_ci_dropped_step_guard' not in str(i.fspath)]\n"
)
res = subprocess.run(
["bash", str(repo / "scripts" / "ci-prove-ban-detects.sh")],
cwd=repo,
env={**os.environ, "GITHUB_WORKSPACE": str(repo)},
capture_output=True,
text=True,
)
assert res.returncode != 0, (
"ci-prove-ban-detects.sh vouched for the gate while the ban test was deselected at the "
f"repo-root config tier.\nstdout:\n{res.stdout}\nstderr:\n{res.stderr}"
)
# NON-ZERO IS NOT ENOUGH — the script could exit non-zero because its own harness broke (a moved
# anchor, an unwritable tree, no python3), which would make this test pass while proving nothing.
# Require one of its real verdicts. Measured, this path yields "pytest exited 5" (nothing
# collected), which is precisely the deselection disarm and precisely the reading an earlier draft
# mistook for success.
combined = res.stdout + res.stderr
# THE SPECIFIC branch, not a disjunction over the script's verdicts. Measured: a total deselect
# makes pytest exit 5, so this lands on "cannot prove anything". Accepting any verdict would let
# the scenario drift onto a branch it was not written for while still looking green — this file's
# own subject, one level down.
assert "could not prove anything" in combined, (
"expected the cannot-prove branch (a total deselect makes pytest exit 5); got a different "
f"verdict, so this test no longer covers what it was written for.\nstdout:\n{res.stdout}\n"
f"stderr:\n{res.stderr}"
)
@_nested
def test_the_PROOF_SCRIPT_refuses_when_the_WRONG_test_fails(tmp_path):
"""The third branch, which the aggregate 'deselect ⇒ non-zero' control does not reach.
Deselecting only the `[build]` parametrisation while some unrelated test fails gives pytest exit
1 — a real test failure, just not the one that proves anything. An earlier draft read that as
success. Reproduced here because that branch was added to fix a live bug and was otherwise
exercised by nothing: making it unreachable left both guard files green.
"""
repo = _repo_copy(tmp_path)
(repo / "conftest.py").write_text(
"def pytest_collection_modifyitems(config, items):\n"
" items[:] = [i for i in items if 'in_any_run_body[build]' not in i.name]\n"
)
# The unrelated failure has to live INSIDE the ban file: the script runs that file and nothing
# else, so a failing test in a sibling module is never collected and the run would exit 0 —
# landing on the "not enforcing" branch instead of the one under test. (First draft of this test
# did exactly that and was red for the wrong reason.)
ban = repo / BAN_TEST_FILE
ban.write_text(ban.read_text() + "\n\ndef test_an_unrelated_failure_for_this_probe():\n assert False\n")
res = subprocess.run(
["bash", str(repo / "scripts" / "ci-prove-ban-detects.sh")],
cwd=repo,
env={**os.environ, "GITHUB_WORKSPACE": str(repo)},
capture_output=True,
text=True,
)
combined = res.stdout + res.stderr
assert res.returncode != 0, f"the script read an unrelated test's failure as proof.\n{combined}"
# THE SPECIFIC branch. This scenario is built to land on "wrong test failed" (exit 1, no `[build]`
# failure); accepting "could not prove anything" too would let it drift onto the exit-5 branch and
# silently cover a branch it was not written for, while still looking green.
assert "NOT the expected one" in combined, (
"expected the wrong-test-failed branch; got a different verdict, so this scenario no longer "
f"covers the branch it was written for.\n{combined}"
)
@_nested
def test_the_PROOF_SCRIPT_passes_on_a_clean_tree(tmp_path):
"""Negative control for the test above: it must not simply always fail."""
repo = _repo_copy(tmp_path)
res = subprocess.run(
["bash", str(repo / "scripts" / "ci-prove-ban-detects.sh")],
cwd=repo,
env={**os.environ, "GITHUB_WORKSPACE": str(repo)},
capture_output=True,
text=True,
)
assert res.returncode == 0, (
f"ci-prove-ban-detects.sh failed on a clean tree.\nstdout:\n{res.stdout}\nstderr:\n{res.stderr}"
)
assert (repo / ".gitea" / "workflows" / "docker-build.yml").read_text() == (WORKFLOW.read_text()), (
"the script did not restore the workflow file it poisoned"
)
def test_the_pytest_invocation_cannot_DESELECT_or_swallow_its_result():
"""Selection flags and shell escapes are the cheap ways to keep the job green while it checks
nothing. `-k`/`-m`/`--deselect`/`--ignore` can drop the ban test from a run that still reports
passes; `|| true` and friends discard the exit status the `needs:` edge depends on."""
line = _pytest_line()
tokens = line.split()
# Only the tokens AFTER `pytest` are pytest's own arguments. Checking the whole line would flag
# the `-m` in `python3 -m pytest`, which is how the interpreter is invoked — a false positive
# that would make this test red on the correct command.
args = tokens[tokens.index("pytest") + 1 :]
banned = {"-k", "-m", "--deselect", "--ignore", "--collect-only", "--co"}
assert not (banned & set(args)), f"pytest invocation may deselect tests: {line!r}"
for op in ("||", "&&", ";", "|"):
assert op not in tokens, f"pytest exit status is not decisive — {op!r} in {line!r}"
def test_the_ban_SCOPE_still_covers_build():
"""The whole release-path property rests on one literal in the sibling module.
`DELIMITER_BAN_JOBS` is what the ban test parametrises over. Drop `"build"` from it and the
suite still reports passes while nothing checks the job that publishes the image — green, and
the release path unguarded. Nothing else in scripts/tests referenced that constant, so this is
the pin. (An earlier design imported the tuple, which pinned it as a side effect; the import
went away with that design and took the protection with it.)
"""
import scripts.tests.test_ci_dropped_step_guard as ban
assert "build" in ban.DELIMITER_BAN_JOBS, (
"`build` was dropped from DELIMITER_BAN_JOBS — the `scan` job would go green while the job "
"that publishes the release candidate is unchecked (ersatztv#767)"
)
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"],
# See `_run_the_real_scan_body` — built from scratch, so the isolated hook-fire log dir has
# to be carried explicitly or the sink falls back to a shared log (ersatztv#809).
hook_fire_isolation.ENV_VAR: os.environ[hook_fire_isolation.ENV_VAR],
"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