Files
ersatztv/scripts/tests/test_ci_status_context_uniqueness.py
T
timothyandtimothy 761e575836
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 / CI toolchain image resolves (push) Successful in 6s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m22s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m56s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m33s
fix(787): derive the dropped-step guard's scope, and reconcile its snapshot against the server (#861)
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-27 22:37:02 +00:00

130 lines
6.3 KiB
Python

"""No two CI jobs may synthesize the SAME status-check context string (ersatztv#787).
Gitea names an Actions status context `<workflow name> / <job name> (<event>)`, and branch protection
identifies a required check by that string ALONE. So two jobs that synthesize the same string are
indistinguishable to it: a required context can be satisfied by whichever of them reports, and a
guard that scopes itself to one of them leaves the other's steps unguarded while every test stays
green. `test_ci_dropped_step_guard.py` resolves the required contexts on `main` to the jobs that must
carry per-step execution markers, and that resolution is only sound if the mapping is injective.
WHY THIS IS ITS OWN FILE rather than an assertion inside the dropped-step guard. That guard is one of
the two files `docker-build.yml`'s release-path `scan` job runs, and it runs them inside a poisoned
COPY of the tree that is NOT a git repository — `git ls-files` exits 128 there. A cross-workflow
claim needs the whole workflow population, the authoritative source for which is the git index
(`testing.guard-derives-population-from-source`, #806), so the two requirements are incompatible in
one file. Splitting them lets each have the right source: the dropped-step guard reads only its own
subject workflow and therefore survives the copy, while this file derives the full population and
runs in `pr-checks.yml::script-tests`, which is a real checkout.
TWO LIMITS, stated so they are not rediscovered as bugs. A workflow declaring no `name:` is skipped
entirely, so a context Gitea would label from the FILENAME is unmodeled. And job names are compared
as raw text, so a name carrying an expression that renders to an existing one at runtime does not
collide here — on the dropped-step side that stays loud (the context resolves to no job and is a hard
failure), only the uniqueness side is quiet about it.
"""
from __future__ import annotations
import pytest
import yaml
from scripts.tests.tracked_files import REPO_ROOT, tracked_paths
WORKFLOWS = (".gitea/workflows", ("*.yml", "*.yaml"))
def workflow_files():
"""THE WORKFLOW POPULATION, from the git index. Named so the shared proof in
`test_guard_populations_derive_from_git.py` can assert it never admits an untracked file."""
return tracked_paths(*WORKFLOWS)
def _events(doc) -> list[str]:
"""YAML 1.1 parses a bare `on:` key as the BOOLEAN `True`, so a lookup that only tried the
string would silently synthesize nothing and make this guard vacuous."""
on = doc.get(True, doc.get("on"))
if isinstance(on, str):
return [on]
if isinstance(on, dict):
return sorted(on)
return sorted(on or [])
def synthesized_contexts() -> dict[str, list[str]]:
"""context string -> every `<workflow file>::<job key>` that can produce it."""
index: dict[str, list[str]] = {}
for path in workflow_files():
doc = yaml.safe_load(path.read_text())
if not isinstance(doc, dict):
continue
workflow_name = doc.get("name")
if not workflow_name:
continue
# The label is for the failure message only. Computed defensively because the detector's own
# proof feeds it a planted workflow from a tmp dir, and a `relative_to` that throws there
# would make the proof fail on plumbing instead of on the collision it plants.
try:
rel = path.relative_to(REPO_ROOT).as_posix()
except ValueError:
rel = str(path)
for job_key, job in (doc.get("jobs") or {}).items():
if not isinstance(job, dict):
continue
job_name = job.get("name") or job_key
for event in _events(doc):
index.setdefault(f"{workflow_name} / {job_name} ({event})", []).append(f"{rel}::{job_key}")
return index
def test_anti_vacuity_the_synthesis_found_a_plausible_number_of_contexts():
"""Every assertion here is over `synthesized_contexts()`, and a synthesis that collapsed to
nothing would satisfy all of them while proving nothing."""
contexts = synthesized_contexts()
assert len(contexts) >= 20, (
f"synthesized only {len(contexts)} contexts from {len(workflow_files())} workflow file(s) — "
"the synthesis is broken, not the repo, and the uniqueness assertion below is vacuous."
)
def test_no_two_jobs_synthesize_the_SAME_status_context():
"""The injectivity `test_ci_dropped_step_guard.py`'s context->job resolution depends on."""
collisions = {ctx: producers for ctx, producers in synthesized_contexts().items() if len(producers) > 1}
assert not collisions, (
"these status contexts can be produced by more than one job: "
+ "; ".join(f"{ctx!r} <- {sorted(producers)}" for ctx, producers in sorted(collisions.items()))
+ ". Branch protection identifies a required check by that string alone, so it cannot tell "
"them apart: a required context could be satisfied by the producer whose steps carry no "
"execution markers, while the guard scoped itself to the other one. Give the workflows or "
"the jobs distinct `name:` values."
)
@pytest.mark.parametrize(
"planted",
[
pytest.param(("Build ErsatzTV Image", "Build & test (.NET)"), id="same-workflow-and-job-name"),
pytest.param(("Build ErsatzTV Image", "EF migration integrity (SQLite + MySql)"), id="other-required-job"),
],
)
def test_the_detector_FIRES_on_a_planted_cross_workflow_twin(planted, tmp_path, monkeypatch):
"""The detector's own proof. A second workflow FILE declaring the same workflow name, job name
and event as a REQUIRED job is the exact evasion this file exists to catch, and it is invisible
to any check that reads only `docker-build.yml`.
"""
workflow_name, job_name = planted
twin = tmp_path / "twin.yml"
twin.write_text(
yaml.safe_dump(
{
"name": workflow_name,
"on": {"pull_request": None},
"jobs": {"innocuous": {"name": job_name, "steps": []}},
}
)
)
monkeypatch.setattr(
"scripts.tests.test_ci_status_context_uniqueness.workflow_files",
lambda: [*tracked_paths(*WORKFLOWS), twin],
)
with pytest.raises(AssertionError, match="more than one job"):
test_no_two_jobs_synthesize_the_SAME_status_context()