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
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
130 lines
6.3 KiB
Python
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()
|