"""No two CI jobs may synthesize the SAME status-check context string (ersatztv#787). Gitea names an Actions status context ` / ()`, 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 `::` 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()