Closes #786 and #789, bundled because working either alone would build the artifact the other removes. Every job in all six tracked workflows declares `env.CI_JOB_ROLE` (guard/report-only/none); the `docker-build.yml` jobs also declare `env.CI_EXECUTION_CLASS` (toolchain/bare-runner). Both guard populations derive from those markers; the `TOOLCHAIN_JOBS`/`BARE_RUNNER_JOBS` literals are deleted. A missing or unrecognised marker is a hard failure in both checkers. #789's literal had a real justification — set equality between two DERIVED sets is blind to a member leaving both at once — so the marker is the anchor that replaces it, and the cost (proximity to the `container:` block) is paid by a THIRD derivation from each job's own steps, which is also the only check that sees the failure #789 filed: a .NET step moved into a bare-runner job, where no set changes. The residual is disclosed: drop the block, flip the marker AND hide the tool behind a script and all three go blind, bounded by the failure mode being a loud missing-binary crash. #786's guard jobs join a machine-checked population: a new `test_workflow_job_guards.py` asserts set equality both ways against a new "Workflow-job guards" table, and the four jobs with no dropped-step guard each carry a recorded decision. Two issue claims were refuted by measurement: #789's "editing docker-build.yml re-points the pin" (the pathspec is `docker/ci` only) and #786's job count (17, not 15). Four cold adversarial review rounds across two model families; rounds 1-3 BLOCKED, all findings fixed and each fix demonstrated by reproducing the reviewer's own test. The recurring defect class was prose drifting from code, including a mechanism claim in the decision record that execution refuted. All five mutation proofs redden when their shipped detector is disarmed. New decision record: `testing.workflow-declares-its-own-job-metadata`. Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
9.7 KiB
key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
| key | title | status | since | supersedes | superseded-by | rule | signals | mechanics |
|---|---|---|---|---|---|---|---|---|
| testing.workflow-declares-its-own-job-metadata | 2026-08-28 — a workflow declares its own per-job metadata, and guard populations derive from those markers rather than from literals in the checker (#786, #789) | active | 2026-08-28 | none | none | A population of workflow JOBS is declared by the workflow itself, one machine-readable marker per job under `env:`, and every set a guard compares is DERIVED from those markers rather than from a literal list in the checker. A missing marker and an unrecognised value are both HARD FAILURES, never a default: a scheme whose absent value reads as some safe class stops applying the moment somebody adds a job and forgets, and the job nobody remembered to mark is the job nobody reviewed. Two markers exist under this rule. `CI_EXECUTION_CLASS` (toolchain / bare-runner) replaced the `TOOLCHAIN_JOBS` and `BARE_RUNNER_JOBS` literals in `test_ci_image_pin_population.py` (#789). `CI_JOB_ROLE` (guard / report-only / none) is the population of workflow-job guards that `docs/guard-inventory.md` was blind to (#786), and it is decided by what the job PRODUCES: a `guard` job output is a VERDICT, a `none` job output is an ARTIFACT, and a red in a `none` job means the build did not work rather than that an invariant was violated. `docker-build.yml::build` publishes an image and smoke-tests it and is `none`. Which jobs are `none` is read off the markers and is deliberately NOT enumerated here — a list in prose is a second copy of the workflow that rots on the next job added, and nothing checks it. TWO READINGS ARE REJECTED and recorded so neither returns. Anything-that-can-fail makes every job a guard and the table distinguishes nothing. And the more principled-sounding repository-versus-product split is worse: it puts `test` and `migrations` outside the population, and those are the two REQUIRED status contexts on `main`, precisely where a failure to fire is fail-OPEN against branch protection and precisely the gap #786 exists to close. THE VALUE IS `report-only` AND NOT `advisory` ON PURPOSE: `docs/ci-cd.md` already calls `functional-e2e` an advisory job in a different sense — not a required context, though it can certainly fail — so reusing the word would make a reader infer the wrong marker for exactly the job whose marker is `guard`. WHY THE MARKER BEATS THE LITERAL it replaced, since the literal had a real justification: set equality between two DERIVED sets is blind to a member leaving both at once, so a job that loses its `container:` block leaves the declared and the pinned set together and the comparison stays balanced. The population therefore needs an anchor that does not move with the thing it guards, and a reviewed literal was the only one available. The marker is that anchor and is strictly better placed: it lives NEXT TO the job, so a reviewer of that job reaches it, and it moves with the job when the job is renamed. THE COST IS ADJACENCY and must be paid explicitly rather than argued away — a literal in a distant file survives a careless workflow edit, whereas a marker a handful of lines from the `container:` block can be deleted along with it in one plausible slip. So a marker scheme is not sufficient on its own: it ships with a THIRD derivation independent of both the marker and the thing it describes. For the toolchain class that is `jobs_whose_steps_need_the_toolchain`, which reads the job OWN step bodies for tools present only in the CI image, and it is the only one of the three that can see the failure #789 was filed for — a .NET-dependent step MOVED into a bare-runner job, where no set changes and every equality stays balanced. That third check is NECESSARY-CONDITION ONLY and says so: step text cannot see a tool reached only through a script, so visible use implies the declaration and the converse is not asserted. MEASURED rather than asserted — the blind-spot set is EMPTY today, all five declared toolchain jobs being detected directly. AND THE RESIDUAL IS REAL: all three checks fail together under one edit that drops the container block, flips the marker AND moves the invocation into a script. What bounds it is the failure MODE — the job then dies on a missing binary, loudly, where the original defect would send a REQUIRED check green on the bare runner. A silent pass traded for a noisy crash, not a hole closed. Filtering to the jobs that visibly invoke a tool is legitimate subject selection for a PER-MEMBER property under `testing.guard-derives-population-from-source`, not a filtered completeness claim; the completeness claims are that every job declares a marker and that the two sets match both ways. WHAT THIS DOES NOT DO, stated because the checks read stronger than they are: it proves every job was CLASSIFIED by someone and that the table and the workflow agree, never that the classification is CORRECT, and an inline YAML assertion cannot be proven to work without running the job, so such a row carries `Proof: NONE` unless a pytest genuinely covers PART of it — `ci-image-pin` and `set-verdict-status` each cite one, and each row says which part, because a proof reference that covers a fraction of a job must not read as covering the job. | CI_EXECUTION_CLASS · CI_JOB_ROLE · per-job marker · workflow declares its own metadata · TOOLCHAIN_JOBS removed · BARE_RUNNER_JOBS removed · missing marker is a hard failure · unknown marker value is a hard failure · the anchor that does not move · adjacency is the cost · third derivation from the job own steps · toolchain-only tools · a step MOVED into a bare-runner job · guard jobs vs guard files · role records purpose not failability · paths: `scripts/tests/test_ci_image_pin_population.py`, `scripts/tests/test_workflow_job_guards.py`, `docs/guard-inventory.md`, `.gitea/workflows/docker-build.yml` · issues: #786, #789, #774, #787, #806 | `CI_EXECUTION_CLASS` is scoped to `docker-build.yml`, the only workflow using the toolchain image — a claim `test_docker_build_is_the_ONLY_workflow_pinning_the_toolchain_image` checks against the tracked workflow set. `CI_JOB_ROLE` spans every tracked workflow. Both populations come from the GIT INDEX per `testing.guard-derives-population-from-source`. The toolchain-only tool list is drawn from `docker/ci/Dockerfile` and deliberately excludes git, jq, python3 and curl. The reason is that they do not DISCRIMINATE: the bare-runner lanes provide or provision them (`small` ships git and adds Python via `actions/setup-python`; `build` runs on `ubuntu-latest`), so a match on one says nothing about whether the job needs the toolchain image. Measured, they flag 4 of the 8 jobs, not all of them. |
A hand-written list of workflow jobs is a filter frozen at authoring time — correct the day it
was written, and unable to report the day it stopped being. TOOLCHAIN_JOBS carried a careful
argument for why it had to be a literal, and the argument was half right: the failure it named is
real, and the conclusion did not follow.
The failure a literal was protecting against
Comparing container_jobs(doc) to pinned_jobs(doc) and nothing else is blind in the worst
direction. Delete a container: block and the job leaves BOTH sets together; the comparison stays
balanced, reports green, and a required check now runs on the bare runner. A guard whose expected
set shrinks in step with the thing it guards is not a guard. The population needs an anchor that
does not move — and in 2026-08 the only anchor available was a reviewed list.
What the marker changes
The workflow can hold the anchor itself. env.CI_EXECUTION_CLASS is just as independent of the
container: block as a literal in a Python file is, and it is better placed: a reviewer editing the
job reaches it, and it moves with the job when the job is renamed or removed. Not "in the same
hunk": measured, the marker sits 6-7 lines from the container: block in four jobs and 58 in
migrations, where the services: block sits between them.
The cost is that same proximity. A literal three directories away survives a careless workflow edit; a marker a handful of lines from the block does not. That is why the scheme is not a marker alone. The third derivation — reading each job's own steps for tools that exist only in the CI image — depends on neither the marker nor the block, so removing the pair together still reddens.
It is also the only check that sees the defect #789 actually filed:
move a .NET-dependent step into
buildorscanwhile leaving that job inBARE_RUNNER_JOBS. Every test stays green while the job now needs the toolchain image and does not have it.
No set changes, so no set-equality can see it. Only the job's own steps can.
Guard JOBS, not only guard files
docs/guard-inventory.md derived its population from guard FILES, so a guard written inline in
workflow YAML belonged to no population at all — which is where pr-checks.yml:ci-image-pin was
sitting when #774 found it stating an invariant it did not check. #786 asked for a reviewed registry
cross-checked both ways; CI_JOB_ROLE supplies the discovery side and the inventory table is the
reviewed side.
The judgement #774 said the filesystem cannot supply is still a human one. What changed is that it is recorded next to the job, and a job that acquires no marker fails the suite rather than passing unnoticed.
The residue
Both checks prove classification happened, never that a classification is right. An inline YAML
assertion cannot be proven to work without running the job, so such a row carries Proof: NONE
rather than borrowing credibility from a neighbouring file's proof — unless a pytest genuinely
covers part of it, which two rows do, and each says which part. Recorded here because a
table that reads as coverage it does not have is the failure this corpus keeps repeating.