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
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
705 lines
37 KiB
Python
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
|