fix(855): two glob dialects cannot be canonicalised into one, so model one shape and refuse the rest
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 7s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 25s
PR Gates / decisions lifecycle (pull_request) Successful in 25s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
Review verdict / Set review-verdict status (pull_request_target) Successful in 25s
review-verdict/h10 Review-verdict: MERGEABLE @ 5e15dd1 (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m30s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 16m51s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m44s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 7s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 25s
PR Gates / decisions lifecycle (pull_request) Successful in 25s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
Review verdict / Set review-verdict status (pull_request_target) Successful in 25s
review-verdict/h10 Review-verdict: MERGEABLE @ 5e15dd1 (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m30s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 16m51s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m44s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
`ci-image.yml`'s `on.push.paths` decides which pushes to `main` publish a toolchain image; `ci-image-pin`'s `git log` pathspec decides what the pin must name. #744 removed the shared self-reference that kept them in step, leaving the agreement carried by three prose comments, and divergence is silent and green in the dangerous direction. The guard derives both lists from the workflow documents and compares them for set equality in both directions. The comparison is deliberately narrow: it accepts a publish entry spelled exactly `<dir>/**` against a pathspec entry spelled exactly `<dir>`, segments restricted to `[A-Za-z0-9._-]`, and raises on every other spelling rather than deciding what that spelling would have selected. That narrowness is the substance. Measured against Gitea 1.27.1's compiler and real git, a bare `docker/ci` in `paths:` compiles to an anchored `^docker/ci` and selects none of the directory's contents while the git pathspec `docker/ci` selects all of them; `<file>/**` matches nothing while the pathspec `<file>` tracks the file; a leading `/` is literal to Gitea while git refuses it outright. A canonicaliser mapping the two dialects onto one string form was built twice and defeated twice, each repair surfacing another spelling, so it was deleted rather than extended per `testing.verification-code-needs-its-own-proof`. The guard also asserts from the git index that each named path really is a directory, since `<file>/**` and the pathspec `<file>` spell the same string; takes the pathspec from the `git log` assignment rather than any `git log` in the job; and refuses a `<<` token on a code line (a herestring excluded) and a second bare `--`, because telling a path separator from an option argument needs git's option arity and refusing needs nothing. The docstring states the boundaries rather than implying coverage: the guard compares the pathspec the pin job writes and does not establish that the staleness comparison consumes it, and a descendant whose name contains a newline is matched by the git pathspec but not by the publish pattern. fixes #855
This commit is contained in:
@@ -60,8 +60,13 @@ on:
|
||||
# this file no longer republishes on its own, so pair it with a `docker/ci/**` edit.
|
||||
#
|
||||
# `paths:` here and `ci-image-pin`'s `expected` pathspec in pr-checks.yml MUST name the same
|
||||
# sources, and nothing mechanically enforces that since the shared self-reference went —
|
||||
# ersatztv#855.
|
||||
# sources. Since the shared self-reference went, `scripts/tests/test_ci_image_paths_pin_agreement.py`
|
||||
# is what holds them together: it derives BOTH lists from these two workflows and compares them for
|
||||
# set equality (ersatztv#855). The two are written in different glob dialects, so it models exactly
|
||||
# one pair of spellings — `<dir>/**` here against the pathspec `<dir>` — and REFUSES anything else
|
||||
# rather than canonicalising a pattern space whose spellings the two consumers treat differently.
|
||||
# Change this list and that guard goes red until the pathspec follows; write it any other way and
|
||||
# it goes red asking for the new shape to be modelled.
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
@@ -106,9 +106,14 @@ jobs:
|
||||
# runner placement than for build recipe. Make a recipe change alongside a `docker/ci/**`
|
||||
# edit — a comment bump suffices, and it is the ONLY remedy: pinning the workflow-only
|
||||
# commit is rejected here, because `expected` is the last `docker/ci` commit.
|
||||
# Nothing MECHANICALLY couples this pathspec to `ci-image.yml`'s `on.push.paths`; before
|
||||
# This pathspec and `ci-image.yml`'s `on.push.paths` MUST name the same sources; before
|
||||
# #744 the shared self-reference kept them in step. Divergence is silent and green in the
|
||||
# dangerous direction — tracked in ersatztv#855.
|
||||
# dangerous direction, so it is enforced rather than asserted:
|
||||
# `scripts/tests/test_ci_image_paths_pin_agreement.py` derives BOTH lists from the two
|
||||
# workflows and compares them for set equality (ersatztv#855). It takes this pathspec from
|
||||
# the ASSIGNMENT below rather than from any `git log` in the job, and models only a plain
|
||||
# `<dir>` against `<dir>/**` there — any other spelling is refused rather than compared.
|
||||
# Change this pathspec and that guard goes red until `on.push.paths` follows.
|
||||
# See docs/ci-cd.md -> "Publishing from a branch is a dispatch, not a push".
|
||||
#
|
||||
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
|
||||
|
||||
+8
-1
@@ -2307,7 +2307,14 @@ through a rebase.
|
||||
> recovery — are #854, deferred on exactly these grounds.
|
||||
|
||||
**A change that lives only in `ci-image.yml` publishes nothing.** That file is no longer one of its
|
||||
own trigger paths, nor one of `ci-image-pin`'s `expected` paths. That pairing is a **decided
|
||||
own trigger paths, nor one of `ci-image-pin`'s `expected` paths. Those two lists must otherwise name
|
||||
the same sources, and `scripts/tests/test_ci_image_paths_pin_agreement.py` holds them to it — it
|
||||
derives both from the workflow documents and compares them for set equality, so editing one alone
|
||||
reddens `script-tests`. That job is not a required context, so the red does not block server-side; it
|
||||
denies the merge-consent auto-grant through the combined status. **Read that guard before taking the
|
||||
reversal described next**: it models a `<dir>/**`-against-`<dir>` pair only, so putting
|
||||
`ci-image.yml` back into both lists is a file-against-file pair it refuses, and would need a file arm
|
||||
added there first (ersatztv#855). That pairing is a **decided
|
||||
tradeoff, not a necessity** — keeping the file in `expected` still works, since the branch dispatch
|
||||
can publish the ci-image.yml commit itself and the pin then matches. What it costs is the reason it
|
||||
went: that route charges a full ~2 GB publish and a five-pin bump for *every* edit to the file, a
|
||||
|
||||
@@ -242,6 +242,7 @@ recorded as unexamined rather than as cleared.
|
||||
| `scripts/tests/test_check_doc_narrative.py` | the `script-tests` job | PROOF | NONE | — |
|
||||
| `scripts/tests/test_ci_dropped_step_guard.py` | the `script-tests` job | PROOF | NONE | — |
|
||||
| `scripts/tests/test_complete_annotation_dispositions.py` | the `script-tests` job, when the SPA guard's `DISPOSITIONS` table disagrees with `test_optional_request_members.py` about a schema both rule on | GUARD | MUTATION | `test_complete_annotation_dispositions.py::test_the_two_dispositions_AGREE` |
|
||||
| `scripts/tests/test_ci_image_paths_pin_agreement.py` | the `script-tests` job, when `ci-image.yml`'s `on.push.paths` and `ci-image-pin`'s `git log` pathspec stop naming the same image sources — the live comparison is `test_the_publish_paths_and_the_pin_pathspec_name_the_same_sources`; the ref below is its mutation proof | GUARD | MUTATION | `test_ci_image_paths_pin_agreement.py::test_a_diverging_list_is_DETECTED` |
|
||||
| `scripts/tests/test_ci_image_pin_population.py` | the `script-tests` job, when a container job loses its pin | GUARD | MUTATION | `test_ci_image_pin_population.py::test_a_single_job_losing_its_pin_is_DETECTED` |
|
||||
| `scripts/tests/test_ci_release_path_scan_job.py` | the `script-tests` job, on a weakened release-path scan job | GUARD | NONE | — |
|
||||
| `scripts/tests/test_ci_status_context_uniqueness.py` | the `script-tests` job, when two CI jobs synthesize the same status-check context (which branch protection cannot tell apart) | GUARD | MUTATION | `test_ci_status_context_uniqueness.py::test_no_two_jobs_synthesize_the_SAME_status_context` |
|
||||
@@ -330,7 +331,7 @@ job it covers, because a proof reference that covers a fraction must not read as
|
||||
| `docker-build.yml::scan` | a merge and the release path, when a banned expression delimiter appears in the `run:` body of a delimiter-ban job (`DELIMITER_BAN_JOBS` = the required contexts **plus `build`**, which is wider than the marked set). It carries no `if:`, so it runs on PRs too and a red blocks through the combined status | GUARD | the ban assertion is `test_ci_dropped_step_guard.py::test_the_delimiter_banned_jobs_have_NO_expression_delimiter_in_any_run_body` — note that file is graded `PROOF` above for its `ci-step-ran.sh` role, and also carries this GUARD-shaped assertion; `test_ci_release_path_scan_job.py` (`GUARD`) protects the job that runs it, and `scripts/ci-prove-ban-detects.sh` proves the ban still detects | `scripts/ci-prove-ban-detects.sh`'s row above — which itself carries `NONE`, as do the two pytest files, so this row claims no proof at all |
|
||||
| `docker-build.yml::api-docs` | a PR whose committed OpenAPI spec, endpoint index or SPA client types are stale | GUARD | inline — `git diff --exit-code` after re-running `scripts/update-openapi.sh` | NONE |
|
||||
| `docker-build.yml::format` | a PR whose changed `.cs` files do not conform to `.editorconfig` | GUARD | inline — `dotnet format --verify-no-changes` | NONE |
|
||||
| `pr-checks.yml::ci-image-pin` | a PR whose `ersatztv-ci` pin is not the 7-char sha of the last commit to touch `docker/ci` | GUARD | inline | `test_ci_image_pin_population.py::test_a_single_job_losing_its_pin_is_DETECTED` covers the POPULATION half only; the git-history half has no test |
|
||||
| `pr-checks.yml::ci-image-pin` | a PR whose `ersatztv-ci` pin is not the 7-char sha of the last commit to touch `docker/ci` | GUARD | inline | `test_ci_image_pin_population.py::test_a_single_job_losing_its_pin_is_DETECTED` covers the POPULATION half; `test_ci_image_paths_pin_agreement.py::test_a_diverging_list_is_DETECTED` covers the PATHSPEC half, bounded: it compares the pathspec this job's `git log` assignment WRITES against `ci-image.yml`'s publish trigger, and does not establish that the staleness comparison consumes that variable. Neither test runs the git-history comparison itself (that `expected` really is the last `docker/ci` commit), which still has no test |
|
||||
| `pr-checks.yml::docs-reminder` | **nothing.** Both of its check steps — and the `Set up Python` between them — carry `continue-on-error: true`, so no finding it makes can fail the job. (`Checkout` does not, so the job can still go red on infrastructure — never on a finding.) | REPORT-ONLY | inline + `scripts/check-doc-narrative.py` | n/a — it cannot go red |
|
||||
| `pr-checks.yml::decisions-guard` | a PR whose decision records fail lifecycle validation, whose catalog is stale, or whose kickoff file drifted | GUARD | `scripts/decisions_validate.py`, `scripts/build_decisions_catalog.py`, `scripts/check-kickoff-guard.sh` | those scripts' rows above |
|
||||
| `pr-checks.yml::prove-fix` | a PR whose `Proves:` trailer names a test that passes without the fix | GUARD | inline + `scripts/prove-fix.sh` | that script's row above |
|
||||
@@ -379,7 +380,7 @@ clause-provable and the entry is regraded.
|
||||
|
||||
## What the numbers say
|
||||
|
||||
46 guards, 6 tooling scripts, 23 proof files. **26 guards carry a mutation proof; 5 are
|
||||
47 guards, 6 tooling scripts, 23 proof files. **27 guards carry a mutation proof; 5 are
|
||||
behaviour-only; 15 have none.** These figures are asserted against the table by
|
||||
`test_the_summary_counts_match_the_table`, because a hand-maintained summary of a table is a second
|
||||
copy of it — the duplication family this change argues against, and one that has already drifted
|
||||
|
||||
@@ -161,6 +161,22 @@ MUTATIONS: tuple[Mutation, ...] = (
|
||||
why="The UNPROVEN branch: a named test that passes WITHOUT the fix must be refused. Same clause "
|
||||
"the proof test names.",
|
||||
),
|
||||
Mutation(
|
||||
guard="scripts/tests/test_ci_image_paths_pin_agreement.py",
|
||||
target="scripts/tests/test_ci_image_paths_pin_agreement.py",
|
||||
clause="for path in sorted(published - required):",
|
||||
replacement="for path in sorted(set()):",
|
||||
proof="test_ci_image_paths_pin_agreement.py::test_a_diverging_list_is_DETECTED",
|
||||
granularity=CLAUSE,
|
||||
expect="the agreement check accepted a workflow pair whose publish paths and pin pathspec",
|
||||
why="The two set-difference clauses fail differently and neither implies the other, so each "
|
||||
"is separately load-bearing: this one catches a path that PUBLISHES an image the pin never "
|
||||
"tracks (silent and green), while `required - published` catches a pathspec entry that "
|
||||
"never publishes (a red on a blocking job until an image for it is published). Disarming "
|
||||
"this clause alone leaves "
|
||||
"the `publish-path-added` mutant undetected while the other four cases stay green, which "
|
||||
"is why the grade is CLAUSE rather than DETECTOR.",
|
||||
),
|
||||
Mutation(
|
||||
guard="scripts/tests/test_ci_image_pin_population.py",
|
||||
target="scripts/tests/test_ci_image_pin_population.py",
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
"""`ci-image.yml`'s publish paths and `ci-image-pin`'s pathspec are held to the same set (ersatztv#855).
|
||||
|
||||
`.gitea/workflows/ci-image.yml`'s `on.push.paths` decides which pushes to `main` publish a toolchain
|
||||
image; the weekly `schedule` and a `workflow_dispatch` build the selected ref's HEAD whatever it
|
||||
touched, so this list governs the push route only. `.gitea/workflows/pr-checks.yml`'s `ci-image-pin`
|
||||
job passes a pathspec to `git log` to decide what the image pin is REQUIRED to name. Divergence is
|
||||
silent and green in the dangerous direction: a path that publishes but is not in the pathspec produces
|
||||
images the pin never tracks, while a path in the pathspec that does not publish reddens a blocking
|
||||
job until an image for it is published (which a `workflow_dispatch` can do, at the cost that decided
|
||||
ersatztv#744). `ci.toolchain-image-publish-is-a-dispatch` removed the shared self-reference
|
||||
that used to keep the two in step.
|
||||
|
||||
Both lists are DERIVED from the workflow documents and compared for SET EQUALITY in both directions
|
||||
(`testing.guard-derives-population-from-source`). Neither LIST is restated as a literal: the live
|
||||
values are read from the parsed documents, so no second copy of either list stands as authority here.
|
||||
(The divergence fixtures below do name synthetic paths of their own — `docker/extra`, `docker/other`,
|
||||
`docker/ci/sub`, `docker` — which is what a fixture is for, and which is why renaming the real source
|
||||
directory to one of THOSE would need an edit here.)
|
||||
|
||||
ACCEPTED: a publish entry spelled exactly `<dir>/**`, against a pathspec entry spelled exactly
|
||||
`<dir>`, where every `/`-segment matches `[A-Za-z0-9._-]+` and is neither `.` nor `..`. Every other
|
||||
spelling RAISES rather than comparing, and this file does not assert what any refused spelling would
|
||||
have selected. A canonicaliser mapping the two dialects onto a common string form is a
|
||||
tested-and-rejected design here, not an untried one.
|
||||
|
||||
MEASURED against Gitea 1.27.1's own compiler — `modules/actions/workflowpattern`, whose
|
||||
`CompilePatterns` calls `modules/glob.CompileWorkflow` — and real git. That is in-tree and is NOT
|
||||
`nektos/act`'s `PatternToRegex`; the two disagree on brace, `**/x` and character-class forms, so
|
||||
anyone extending the accepted shape must read the Gitea one.
|
||||
This is why the accepted pair is the one modelled:
|
||||
|
||||
* `docker/ci/**` compiles to `^docker/ci/.*\\Z`, and the pathspec `docker/ci` selects that subtree.
|
||||
* a bare `docker/ci` in `paths:` compiles to `^docker/ci\\Z` and selects NONE of the directory's
|
||||
contents, while the pathspec `docker/ci` selects all of them.
|
||||
* `<file>/**` matches nothing, while the pathspec `<file>` tracks the file. The two spell the same
|
||||
string, so `test_every_named_directory_is_a_tracked_DIRECTORY` decides it from the git index.
|
||||
* a leading `/` is literal to Gitea, while git refuses it outright (`fatal: Invalid path`).
|
||||
|
||||
RESIDUAL, measured: Gitea compiles `/**` to a Go regex whose `.` excludes newline, so any descendant
|
||||
whose path below `<dir>` CONTAINS A NEWLINE — in its own name or in that of any directory above it —
|
||||
is selected by the git pathspec and not by the publish pattern. The accepted pair still selects
|
||||
different sets for such a file, and no refusal here closes that.
|
||||
|
||||
BOUNDARY: this compares the pathspec the pin job WRITES. It does not establish that the job's
|
||||
staleness comparison CONSUMES that pathspec — a job could assign it, read it in a diagnostic, and
|
||||
compare something else, and only `ci-image-pin` itself going red would show that.
|
||||
|
||||
THE `<<` REFUSAL IS TEXTUAL: a `<<` token on a code line of the pin job raises, because a heredoc body
|
||||
is data that this reader would otherwise treat as code. A herestring `<<<` is excluded, since
|
||||
`pr-checks.yml` already uses one in a sibling job; an arithmetic shift is not excluded and raises.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from scripts.tests.tracked_files import tracked_under
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
IMAGE_WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "ci-image.yml"
|
||||
CHECKS_WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "pr-checks.yml"
|
||||
|
||||
# The job whose `git log` pathspec is the second of the two lists.
|
||||
PIN_JOB = "ci-image-pin"
|
||||
|
||||
# `paths:` patterns and git pathspecs share no glob dialect. Only the recursive-directory suffix is
|
||||
# modelled, because only it is known to denote the same set on both sides.
|
||||
RECURSIVE_SUFFIX = "/**"
|
||||
|
||||
|
||||
class ExtractionError(AssertionError):
|
||||
"""A workflow no longer has the shape this guard reads its lists out of.
|
||||
|
||||
Raised rather than returned: an extraction that quietly yields an empty list would make the set
|
||||
comparison below trivially true, which is the vacuity this guard exists to avoid reporting.
|
||||
"""
|
||||
|
||||
|
||||
class Unnormalisable(AssertionError):
|
||||
"""A path shape `_directory` does not model, so agreement cannot be decided for it."""
|
||||
|
||||
|
||||
def _triggers(doc: dict) -> dict:
|
||||
"""`on:` — YAML 1.1 resolves the bare key to the boolean True, so accept either spelling."""
|
||||
for key in ("on", True):
|
||||
section = doc.get(key)
|
||||
if isinstance(section, dict):
|
||||
return section
|
||||
raise ExtractionError(f"{IMAGE_WORKFLOW.name}: no usable `on:` mapping (tried 'on' and True)")
|
||||
|
||||
|
||||
def publish_paths(image_doc: dict) -> list[str]:
|
||||
"""The `on.push.paths` list — what causes ci-image.yml to publish a toolchain image."""
|
||||
push = _triggers(image_doc).get("push")
|
||||
if not isinstance(push, dict):
|
||||
raise ExtractionError(f"{IMAGE_WORKFLOW.name}: `on.push` is not a mapping")
|
||||
if "paths-ignore" in push:
|
||||
raise ExtractionError(
|
||||
f"{IMAGE_WORKFLOW.name}: `on.push` declares `paths-ignore`, which SUBTRACTS from the set "
|
||||
"`paths:` selects. This guard models no subtraction, so it would compare the unreduced "
|
||||
"list and report an agreement that does not hold. Model it here deliberately or drop the "
|
||||
"key."
|
||||
)
|
||||
paths = push.get("paths")
|
||||
if not isinstance(paths, list) and paths is not None:
|
||||
raise ExtractionError(f"{IMAGE_WORKFLOW.name}: `on.push.paths` is not a list")
|
||||
if not paths:
|
||||
# An ABSENT key and an EMPTY list are the same thing to the matcher, and both are the
|
||||
# dangerous direction. Gitea compiles the list and treats "no patterns" as "do not skip"
|
||||
# (`workflowpattern.Skip` returns false on an empty sequence), so either spelling means
|
||||
# EVERY push to a matched branch publishes — never "publishes nothing". Diagnosing them
|
||||
# apart, or diagnosing the empty list as an absence of publishing, would name the safe
|
||||
# failure for the dangerous one.
|
||||
raise ExtractionError(
|
||||
f"{IMAGE_WORKFLOW.name}: `on.push` has no effective `paths:` filter "
|
||||
f"({paths!r}), so EVERY push to a matched branch publishes. No pathspec can agree with "
|
||||
"that — either restore the filter or retire this guard deliberately."
|
||||
)
|
||||
if not all(isinstance(p, str) for p in paths):
|
||||
raise ExtractionError(f"{IMAGE_WORKFLOW.name}: `on.push.paths` is not a list of strings")
|
||||
return list(paths)
|
||||
|
||||
|
||||
def _pin_step_code(checks_doc: dict) -> list[str]:
|
||||
"""Every non-comment line of every `run:` body in the pin job.
|
||||
|
||||
Comments are dropped before the search below: this job's `run:` body carries far more prose than
|
||||
code, and a `git log` written inside a comment is not an invocation.
|
||||
"""
|
||||
jobs = checks_doc.get("jobs")
|
||||
if not isinstance(jobs, dict) or PIN_JOB not in jobs:
|
||||
raise ExtractionError(f"{CHECKS_WORKFLOW.name}: no `{PIN_JOB}` job")
|
||||
steps = jobs[PIN_JOB].get("steps")
|
||||
if not isinstance(steps, list):
|
||||
raise ExtractionError(f"{CHECKS_WORKFLOW.name}: `{PIN_JOB}` has no `steps:` list")
|
||||
lines: list[str] = []
|
||||
for step in steps:
|
||||
if not isinstance(step, dict) or not isinstance(step.get("run"), str):
|
||||
continue
|
||||
for ln in step["run"].splitlines():
|
||||
if ln.lstrip().startswith("#"):
|
||||
continue
|
||||
# A heredoc body is DATA, not executable lines, so an assignment written inside one is
|
||||
# inert, and this reader cannot tell body from code without tracking the delimiter. The
|
||||
# test is textual and the message says so. A herestring is excluded because it opens no
|
||||
# body and `pr-checks.yml` already uses one in a sibling job; an arithmetic shift is not
|
||||
# excluded and is a disclosed false red.
|
||||
if "<<" in ln.replace("<<<", ""):
|
||||
raise ExtractionError(
|
||||
f"{CHECKS_WORKFLOW.name}: `{PIN_JOB}` has a `<<` token on a code line "
|
||||
f"({ln.strip()!r}). If it opens a heredoc, this reader would treat the body as "
|
||||
"executable lines, so the shape is refused rather than parsed by guesswork."
|
||||
)
|
||||
lines.append(ln)
|
||||
return lines
|
||||
|
||||
|
||||
# The pathspec lives in an assignment whose whole right-hand side is a command substitution:
|
||||
# `expected="$(git log -1 --format=%H -- docker/ci)"`. Binding to the ASSIGNMENT rather than to "the
|
||||
# only `git log` in the job" is what stops an UNBOUND one being adopted: a `git log` in an inline
|
||||
# comment or a heredoc body, left behind after the real assignment was rewired to something else.
|
||||
# It does NOT establish which variable the job acts on — a diagnostic that is itself an assignment
|
||||
# and is read IS adopted — and the docstring's boundary section says so.
|
||||
_ASSIGNED_SUBSTITUTION = re.compile(r"^\s*(?P<name>[A-Za-z_][A-Za-z0-9_]*)=\"?\$\(\s*(?P<cmd>[^()]*?)\s*\)\"?\s*$")
|
||||
_GIT_LOG = re.compile(r"\bgit\s+log\b")
|
||||
|
||||
|
||||
def pin_pathspec(checks_doc: dict) -> list[str]:
|
||||
"""The pathspec `ci-image-pin` passes to `git log` — what the pin is required to name."""
|
||||
code = _pin_step_code(checks_doc)
|
||||
assignments = [
|
||||
(m.group("name"), m.group("cmd"))
|
||||
for line in code
|
||||
if (m := _ASSIGNED_SUBSTITUTION.match(line)) and _GIT_LOG.search(m.group("cmd"))
|
||||
]
|
||||
loose = [line.strip() for line in code if _GIT_LOG.search(line) and not _ASSIGNED_SUBSTITUTION.match(line)]
|
||||
if loose:
|
||||
raise ExtractionError(
|
||||
f"{CHECKS_WORKFLOW.name}: `{PIN_JOB}` runs `git log` outside an assignment: {loose!r}. "
|
||||
"The pathspec this guard compares must be the one the job ACTS on, so a `git log` whose "
|
||||
"result is not bound to a variable is refused rather than silently adopted."
|
||||
)
|
||||
if len(assignments) != 1:
|
||||
raise ExtractionError(
|
||||
f"{CHECKS_WORKFLOW.name}: expected exactly one `git log` assignment in `{PIN_JOB}`, "
|
||||
f"found {len(assignments)}: {assignments!r}. With none there is nothing to read the "
|
||||
"pathspec from; with several, choosing by position would be a guess. Extend this "
|
||||
"extractor and say which assignment carries the pathspec."
|
||||
)
|
||||
name, invocation = assignments[0]
|
||||
if not any(re.search(r"\$\{?" + re.escape(name) + r"\b", line) for line in code):
|
||||
raise ExtractionError(
|
||||
f"{CHECKS_WORKFLOW.name}: `{name}` is assigned from `git log` in `{PIN_JOB}` but never "
|
||||
"read, so its pathspec is not what the job checks against. Comparing it would report "
|
||||
"agreement about a value nothing consumes."
|
||||
)
|
||||
tokens = shlex.split(invocation)
|
||||
separators = tokens.count("--")
|
||||
if separators == 0:
|
||||
raise ExtractionError(
|
||||
f"{CHECKS_WORKFLOW.name}: `{invocation}` has no `--` separator, so its path arguments "
|
||||
"cannot be told from its revision arguments."
|
||||
)
|
||||
if separators > 1:
|
||||
# A bare `--` is not necessarily THE separator: it can be the argument of a preceding
|
||||
# option (`--decorate-refs "--"`), and telling those apart needs git's option arity table.
|
||||
# Refusing more than one is the answer that needs no such table.
|
||||
raise ExtractionError(
|
||||
f"{CHECKS_WORKFLOW.name}: `{invocation}` contains {separators} bare `--` tokens. Only "
|
||||
"one can be the path separator and the others are option arguments, which this guard "
|
||||
"cannot tell apart without git's option arity. Refused rather than resolved by position."
|
||||
)
|
||||
paths = tokens[tokens.index("--") + 1 :]
|
||||
if not paths:
|
||||
raise ExtractionError(
|
||||
f"{CHECKS_WORKFLOW.name}: `{invocation}` names no path after `--`, so it matches the "
|
||||
"whole tree and the pin tracks every commit."
|
||||
)
|
||||
return paths
|
||||
|
||||
|
||||
PUBLISH_ORIGIN = "ci-image.yml on.push.paths"
|
||||
PIN_ORIGIN = "pr-checks.yml ci-image-pin's git-log pathspec"
|
||||
|
||||
|
||||
# ONE SHAPE IS MODELLED AND EVERYTHING ELSE IS REFUSED. An open-ended canonicaliser over these two
|
||||
# dialects is a tested-and-rejected design: among the spellings it mapped onto the modelled stem were
|
||||
# pairs the two consumers select differently — a bare directory, a leading `/`, and a path that is a
|
||||
# FILE rather than a directory (all three measured; see the module docstring). The comparison
|
||||
# therefore does not canonicalise at all. A publish entry must be `<dir>/**` and a pathspec entry
|
||||
# must be `<dir>`, where `<dir>` is built only from segments that carry no special meaning in EITHER
|
||||
# dialect. That is the single pair of spellings whose selected set is unambiguous on both sides;
|
||||
# every other spelling raises and asks to be modelled deliberately. The refusal set is wide on
|
||||
# purpose — a red asks a question, while a false GREEN is the silent divergence this guard exists
|
||||
# to stop.
|
||||
_SAFE_SEGMENT = re.compile(r"[A-Za-z0-9._-]+\Z")
|
||||
_RESERVED_SEGMENTS = frozenset({".", ".."})
|
||||
|
||||
|
||||
def _directory(raw: str, origin: str) -> str:
|
||||
segments = raw.split("/")
|
||||
bad = [s for s in segments if not _SAFE_SEGMENT.match(s) or s in _RESERVED_SEGMENTS]
|
||||
if bad:
|
||||
raise Unnormalisable(
|
||||
f"{origin}: {raw!r} has segment(s) {bad} outside the plain-path form this guard models. "
|
||||
"A `paths:` pattern and a git pathspec agree on the set selected by a plain directory "
|
||||
"path; outside it they either select differently (a leading `/` or `./`) or cannot be "
|
||||
"compared as written (a wildcard, a brace, an empty segment). A bare `<dir>` on the "
|
||||
"publish side is refused earlier, by `_publish_directory`. Model the new shape here "
|
||||
"deliberately rather than letting it compare as a literal."
|
||||
)
|
||||
return raw
|
||||
|
||||
|
||||
def _publish_directory(raw: str) -> str:
|
||||
"""A `paths:` entry -> the directory it selects recursively.
|
||||
|
||||
Gitea compiles a `paths:` entry as an ANCHORED match, so `docker/ci/**` selects everything under
|
||||
`docker/ci` while a bare `docker/ci` selects the literal path and none of its contents. Only the
|
||||
explicit recursive form is accepted, because it is the only one that denotes what the git
|
||||
pathspec `docker/ci` denotes.
|
||||
"""
|
||||
if not raw.endswith(RECURSIVE_SUFFIX):
|
||||
raise Unnormalisable(
|
||||
f"{PUBLISH_ORIGIN}: {raw!r} is not `<dir>{RECURSIVE_SUFFIX}`, the only publish spelling "
|
||||
"this guard models. That form is modelled because its selected set matches the git "
|
||||
"pathspec `<dir>`; no claim is made here about what any other spelling selects. Model "
|
||||
"the new shape deliberately rather than widening this check to admit it."
|
||||
)
|
||||
return _directory(raw[: -len(RECURSIVE_SUFFIX)], PUBLISH_ORIGIN)
|
||||
|
||||
|
||||
def _pin_directory(raw: str) -> str:
|
||||
"""A git pathspec entry -> the directory it selects recursively."""
|
||||
return _directory(raw, PIN_ORIGIN)
|
||||
|
||||
|
||||
def agreement_faults(image_doc: dict, checks_doc: dict) -> list[str]:
|
||||
"""Set equality in BOTH directions between the two derived lists.
|
||||
|
||||
The two clauses fail differently and neither implies the other, which is why they are separate
|
||||
and why each is separately proven below.
|
||||
"""
|
||||
published = {_publish_directory(p) for p in publish_paths(image_doc)}
|
||||
required = {_pin_directory(p) for p in pin_pathspec(checks_doc)}
|
||||
faults = []
|
||||
for path in sorted(published - required):
|
||||
faults.append(
|
||||
f"{path!r} is named by ci-image.yml's on.push.paths and NOT by ci-image-pin's git-log "
|
||||
"pathspec, so the two lists no longer name the same image sources"
|
||||
)
|
||||
for path in sorted(required - published):
|
||||
faults.append(
|
||||
f"{path!r} is named by ci-image-pin's git-log pathspec and NOT by ci-image.yml's "
|
||||
"on.push.paths, so the two lists no longer name the same image sources"
|
||||
)
|
||||
return faults
|
||||
|
||||
|
||||
_IMAGE_DOC = yaml.safe_load(IMAGE_WORKFLOW.read_text())
|
||||
_CHECKS_DOC = yaml.safe_load(CHECKS_WORKFLOW.read_text())
|
||||
|
||||
|
||||
def test_both_lists_extract_and_are_NOT_empty():
|
||||
"""Anti-vacuity. Two empty sets compare equal, so a broken extractor would make the agreement
|
||||
assertion below pass while establishing nothing — the characteristic failure of a completeness
|
||||
check (`testing.guard-ships-with-mutation-proof`)."""
|
||||
published = publish_paths(_IMAGE_DOC)
|
||||
required = pin_pathspec(_CHECKS_DOC)
|
||||
assert published, f"{PUBLISH_ORIGIN} extracted as empty; the agreement check would be vacuous"
|
||||
assert required, f"{PIN_ORIGIN} extracted as empty; the agreement check would be vacuous"
|
||||
|
||||
|
||||
def test_every_extracted_path_is_one_the_MODELLED_shape_admits():
|
||||
"""A shape `_directory` does not model must be a red asking for an extension, never a silent
|
||||
literal comparison between two different glob dialects."""
|
||||
{_publish_directory(p) for p in publish_paths(_IMAGE_DOC)}
|
||||
{_pin_directory(p) for p in pin_pathspec(_CHECKS_DOC)}
|
||||
|
||||
|
||||
def test_the_publish_paths_and_the_pin_pathspec_name_the_same_sources():
|
||||
"""The live assertion — the invariant ersatztv#855 was opened to stop carrying in prose."""
|
||||
faults = agreement_faults(_IMAGE_DOC, _CHECKS_DOC)
|
||||
assert not faults, (
|
||||
"ci-image.yml's publish paths and ci-image-pin's git-log pathspec name DIFFERENT image "
|
||||
"sources:\n " + "\n ".join(faults)
|
||||
)
|
||||
|
||||
|
||||
def _live_assignment() -> str:
|
||||
"""The pin job's `git log` assignment line, DERIVED from the document.
|
||||
|
||||
A literal copy here would be a hand-maintained mirror of the very line this guard exists to stop
|
||||
anyone maintaining by hand, and it strands every fixture below on a stale anchor the moment the
|
||||
pathspec is edited — which is exactly the co-ordinated edit the workflow comments promise will
|
||||
clear a red.
|
||||
"""
|
||||
for line in _pin_step_code(_CHECKS_DOC):
|
||||
match = _ASSIGNED_SUBSTITUTION.match(line)
|
||||
if match and _GIT_LOG.search(match.group("cmd")):
|
||||
return line.strip()
|
||||
raise ExtractionError(f"{CHECKS_WORKFLOW.name}: no `git log` assignment in `{PIN_JOB}` for fixtures to anchor on")
|
||||
|
||||
|
||||
def _rewire_pin_job(replacement: str) -> dict:
|
||||
"""Replace the pin job's `git log` assignment, asserting the edit LANDED.
|
||||
|
||||
A fixture whose anchor stops matching silently arranges nothing, and the test then reports on the
|
||||
unmodified document — so the substitution count is checked rather than assumed.
|
||||
"""
|
||||
anchor = _live_assignment()
|
||||
doc = copy.deepcopy(_CHECKS_DOC)
|
||||
applied = 0
|
||||
for step in doc["jobs"][PIN_JOB]["steps"]:
|
||||
if isinstance(step, dict) and isinstance(step.get("run"), str):
|
||||
# CODE lines only. A comment quoting the assignment — documenting the very line this
|
||||
# guard reads is a plausible thing to write — would otherwise be counted, and the
|
||||
# `applied == 1` assertion below would redden every fixture while the invariant holds.
|
||||
rebuilt = []
|
||||
for ln in step["run"].splitlines(keepends=True):
|
||||
if not ln.lstrip().startswith("#") and anchor in ln:
|
||||
applied += ln.count(anchor)
|
||||
ln = ln.replace(anchor, replacement)
|
||||
rebuilt.append(ln)
|
||||
step["run"] = "".join(rebuilt)
|
||||
assert applied == 1, (
|
||||
f"the pin-job fixture derived the anchor {anchor!r} and matched it {applied} times, so the "
|
||||
"document it is about to test is not the one it arranged."
|
||||
)
|
||||
return doc
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spelling", [None, []], ids=["absent", "empty-list"])
|
||||
def test_NO_EFFECTIVE_paths_filter_is_refused_in_the_DANGEROUS_direction(spelling):
|
||||
"""An absent `paths:` and an empty one are the same thing to the matcher, and both mean EVERY
|
||||
push to a matched branch publishes. Gitea compiles the list and its `Skip` returns false on an
|
||||
empty sequence, so `paths: []` filters nothing. Reporting it as "publishes nothing" would name
|
||||
the safe failure for the dangerous one, and would invite dropping the pathspec to match."""
|
||||
doc = copy.deepcopy(_IMAGE_DOC)
|
||||
if spelling is None:
|
||||
_triggers(doc)["push"].pop("paths")
|
||||
else:
|
||||
_triggers(doc)["push"]["paths"] = spelling
|
||||
with pytest.raises(ExtractionError, match="EVERY push"):
|
||||
publish_paths(doc)
|
||||
|
||||
|
||||
def test_a_paths_ignore_key_is_REFUSED():
|
||||
"""`paths-ignore` subtracts from what `paths:` selects. Comparing the unreduced list would report
|
||||
an agreement that does not hold, so the shape is refused rather than approximated."""
|
||||
doc = copy.deepcopy(_IMAGE_DOC)
|
||||
_triggers(doc)["push"]["paths-ignore"] = ["docker/ci/Dockerfile"]
|
||||
with pytest.raises(ExtractionError, match="paths-ignore"):
|
||||
publish_paths(doc)
|
||||
|
||||
|
||||
def test_a_git_log_OUTSIDE_an_assignment_is_REFUSED():
|
||||
"""The pathspec must be the one the job acts on. A `git log` whose result is not bound to a
|
||||
variable — a diagnostic line left behind after `expected` was rewired — must not be adopted as
|
||||
the subject of the comparison."""
|
||||
doc = _rewire_pin_job('expected="$(git rev-parse HEAD)"\n echo "$(git log -1 --format=%H -- docker/ci)"')
|
||||
with pytest.raises(ExtractionError, match="outside an assignment"):
|
||||
pin_pathspec(doc)
|
||||
|
||||
|
||||
def test_a_git_log_assignment_that_is_never_READ_is_REFUSED():
|
||||
"""A variable nothing consumes is not what the job checks against."""
|
||||
doc = copy.deepcopy(_CHECKS_DOC)
|
||||
for step in doc["jobs"][PIN_JOB]["steps"]:
|
||||
if isinstance(step, dict) and isinstance(step.get("run"), str) and "git log" in step["run"]:
|
||||
step["run"] = (
|
||||
step["run"]
|
||||
.replace("expected=", "unused_audit=")
|
||||
.replace("${expected}", "X")
|
||||
.replace('"$expected"', '"X"')
|
||||
.replace("${expected:0:7}", "X")
|
||||
)
|
||||
with pytest.raises(ExtractionError, match="never"):
|
||||
pin_pathspec(doc)
|
||||
|
||||
|
||||
def test_an_UNMODELLED_publish_spelling_is_REFUSED():
|
||||
"""Spellings a canonicaliser would have admitted, none of which may compare here.
|
||||
|
||||
Some canonicalise onto the modelled stem and select a DIFFERENT set from it — `docker/ci` is an
|
||||
anchored match selecting the literal path, and a leading `/` is literal to Gitea while git
|
||||
REFUSES it outright (`fatal: Invalid path`). Others do not reduce to the stem at all, a brace or
|
||||
wildcard form among them. The guard does not distinguish those cases: every spelling outside the
|
||||
modelled one raises."""
|
||||
for spelling in (
|
||||
"docker/ci",
|
||||
"docker/ci/",
|
||||
"docker/ci/**/*",
|
||||
"./docker/ci/**",
|
||||
"docker/{ci,extra}/**",
|
||||
"/docker/ci/**",
|
||||
"docker/*/**",
|
||||
"docker/../docker/ci/**",
|
||||
):
|
||||
doc = copy.deepcopy(_IMAGE_DOC)
|
||||
_triggers(doc)["push"]["paths"] = [spelling]
|
||||
with pytest.raises(Unnormalisable):
|
||||
agreement_faults(doc, _CHECKS_DOC)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spelling", [" docker/ci/**", "docker/ci/** ", "docker/ci /**"])
|
||||
def test_a_whitespace_padded_publish_pattern_is_REFUSED(spelling):
|
||||
"""The matcher takes padding literally, so trimming it would compare a pattern Gitea never
|
||||
applies. Trailing padding fails the recursive-form check and the others fail the segment check —
|
||||
what matters is that none of them compares."""
|
||||
doc = copy.deepcopy(_IMAGE_DOC)
|
||||
_triggers(doc)["push"]["paths"] = [spelling]
|
||||
with pytest.raises(Unnormalisable):
|
||||
agreement_faults(doc, _CHECKS_DOC)
|
||||
|
||||
|
||||
def test_a_git_log_in_an_INLINE_comment_is_REFUSED():
|
||||
"""`_pin_step_code` drops whole-line comments only, so an inline `#` leaves a `git log` in the
|
||||
token stream that never executes. Binding to the assignment is what refuses it."""
|
||||
doc = _rewire_pin_job('expected="$(git rev-parse HEAD)"\n : # $(git log -1 --format=%H -- docker/ci)')
|
||||
with pytest.raises(ExtractionError, match="outside an assignment"):
|
||||
pin_pathspec(doc)
|
||||
|
||||
|
||||
def test_every_named_directory_is_a_tracked_DIRECTORY():
|
||||
"""`<file>/**` matches nothing while the pathspec `<file>` tracks the file, so the two sides can
|
||||
name the same string and select different sets. The index is what settles it: a path with no
|
||||
tracked file beneath it is not a directory."""
|
||||
for origin, names in (
|
||||
(PUBLISH_ORIGIN, {_publish_directory(p) for p in publish_paths(_IMAGE_DOC)}),
|
||||
(PIN_ORIGIN, {_pin_directory(p) for p in pin_pathspec(_CHECKS_DOC)}),
|
||||
):
|
||||
for name in sorted(names):
|
||||
assert tracked_under(name), (
|
||||
f"{origin}: {name!r} has no tracked file beneath it, so it is a file or absent "
|
||||
"rather than a directory. The publish pattern and the pathspec select different "
|
||||
"sets for such a path even when they spell it identically."
|
||||
)
|
||||
|
||||
|
||||
def test_a_SECOND_bare_separator_is_REFUSED():
|
||||
"""A bare `--` can be an option's argument (`--decorate-refs "--"`), so the first one is not
|
||||
necessarily the path separator. Telling them apart needs git's option arity table; refusing more
|
||||
than one needs nothing."""
|
||||
doc = _rewire_pin_job("expected=\"$(git log -1 --decorate-refs '--' -- docker/ci)\"")
|
||||
with pytest.raises(ExtractionError, match="bare `--` tokens"):
|
||||
pin_pathspec(doc)
|
||||
|
||||
|
||||
def test_a_HERESTRING_is_NOT_treated_as_a_heredoc():
|
||||
"""A herestring opens no body, so nothing in it is mistaken for code. Excluded explicitly because
|
||||
`pr-checks.yml` already uses one in a sibling job, which makes `mapfile -t pins <<< "$(...)"` an
|
||||
ordinary refactor of this job rather than a hypothetical."""
|
||||
doc = _rewire_pin_job(_live_assignment() + '\n mapfile -t x <<< "$(grep -oE y f)"')
|
||||
assert pin_pathspec(doc) == pin_pathspec(_CHECKS_DOC), (
|
||||
"adding a herestring changed what the extractor returned. Expected is DERIVED from the live "
|
||||
"document, not a literal, so a co-ordinated rename of both lists leaves this test green."
|
||||
)
|
||||
|
||||
|
||||
def test_a_HEREDOC_in_the_pin_job_is_REFUSED():
|
||||
"""A heredoc body is data. An assignment written inside one is inert, and a reader that treats
|
||||
it as code adopts a pathspec the job never uses."""
|
||||
doc = _rewire_pin_job(
|
||||
'expected="$(git rev-list -1 HEAD -- docs)"\n'
|
||||
" cat <<'STOP'\n"
|
||||
' audit="$(git log -1 --format=%H -- docker/ci)"\n'
|
||||
' echo "$audit"\n'
|
||||
" STOP"
|
||||
)
|
||||
with pytest.raises(ExtractionError, match="heredoc"):
|
||||
pin_pathspec(doc)
|
||||
|
||||
|
||||
def _with_publish_paths(paths: list[str]) -> dict:
|
||||
doc = copy.deepcopy(_IMAGE_DOC)
|
||||
_triggers(doc)["push"]["paths"] = paths
|
||||
return doc
|
||||
|
||||
|
||||
def _with_pin_pathspec(replacement: str) -> dict:
|
||||
"""Rewrite the pathspec inside the pin job's real `run:` body, so the mutant exercises the same
|
||||
extraction path the live document does rather than a hand-built stand-in."""
|
||||
doc = copy.deepcopy(_CHECKS_DOC)
|
||||
current = " ".join(pin_pathspec(_CHECKS_DOC))
|
||||
for step in doc["jobs"][PIN_JOB]["steps"]:
|
||||
if isinstance(step, dict) and isinstance(step.get("run"), str) and "git log" in step["run"]:
|
||||
step["run"] = step["run"].replace(f"-- {current})", f"-- {replacement})")
|
||||
assert pin_pathspec(doc) == shlex.split(replacement), (
|
||||
f"the pathspec mutant did not take: wanted {replacement!r}, extraction returned {pin_pathspec(doc)!r}"
|
||||
)
|
||||
return doc
|
||||
|
||||
|
||||
def _mutants():
|
||||
"""One divergence per case, each isolating a single direction of the comparison where it can.
|
||||
|
||||
Every mutant keeps both lists NON-EMPTY and inside the modelled shape, so a red here is the
|
||||
agreement clause speaking rather than an extractor or a shape refusal.
|
||||
"""
|
||||
live = publish_paths(_IMAGE_DOC)
|
||||
|
||||
# published > required: a new source publishes images the pin will never name.
|
||||
yield "publish-path-added", (_with_publish_paths([*live, "docker/extra/**"]), _CHECKS_DOC)
|
||||
|
||||
# published < required: the publish trigger narrows while the pathspec stays wide.
|
||||
yield "publish-path-narrowed", (_with_publish_paths(["docker/ci/sub/**"]), _CHECKS_DOC)
|
||||
|
||||
# published disjoint from required: the publish trigger is retargeted wholesale.
|
||||
yield "publish-path-retargeted", (_with_publish_paths(["docker/other/**"]), _CHECKS_DOC)
|
||||
|
||||
# required > published: the pathspec widens, so an unpublished path reddens a blocking job.
|
||||
yield "pathspec-widened", (_IMAGE_DOC, _with_pin_pathspec("docker"))
|
||||
|
||||
# required grows a second entry the publish trigger does not carry.
|
||||
yield "pathspec-extra-entry", (_IMAGE_DOC, _with_pin_pathspec("docker/ci docker/extra"))
|
||||
|
||||
|
||||
_MUTANTS = list(_mutants())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pair", [m for _, m in _MUTANTS], ids=[i for i, _ in _MUTANTS])
|
||||
def test_a_diverging_list_is_DETECTED(pair):
|
||||
"""The proof this guard can go red. Without it, `agreement_faults` returning a constant empty
|
||||
list would satisfy the live assertion above and prove nothing."""
|
||||
image_doc, checks_doc = pair
|
||||
assert agreement_faults(image_doc, checks_doc), (
|
||||
"the agreement check accepted a workflow pair whose publish paths and pin pathspec name different image sources"
|
||||
)
|
||||
|
||||
|
||||
def test_the_mutation_set_is_not_empty():
|
||||
"""The positive control for the parametrisation itself: if `_mutants` yielded nothing, pytest
|
||||
would collect zero cases above and the file would report all-green having proved nothing."""
|
||||
assert len(_MUTANTS) == 5, (
|
||||
f"expected 5 divergence mutants, got {len(_MUTANTS)}. An equality rather than a floor, so a "
|
||||
"generator that degraded to fewer cases cannot pass while silently testing less."
|
||||
)
|
||||
faults = [f for _, pair in _MUTANTS for f in agreement_faults(*pair)]
|
||||
assert any("named by ci-image.yml's on.push.paths and NOT" in f for f in faults), (
|
||||
"no mutant produces a published-side fault, so disarming the `published - required` clause "
|
||||
"would leave this file green"
|
||||
)
|
||||
assert any("named by ci-image-pin's git-log pathspec and NOT" in f for f in faults), (
|
||||
"no mutant produces a pathspec-side fault, so disarming the `required - published` clause "
|
||||
"would leave this file green"
|
||||
)
|
||||
@@ -129,6 +129,12 @@ _PAIRS = [(label, derive) for label, derive, _ in DERIVATIONS]
|
||||
POPULATION_EXEMPT = {
|
||||
# Uses the index to assemble a HERMETIC tmp fixture copy; nothing in it asserts membership.
|
||||
"test_ci_release_path_scan_job.py": "index-derived fixture copy, not a population",
|
||||
# Its population is the two path lists parsed out of ci-image.yml and pr-checks.yml, derived
|
||||
# from those documents rather than from the index. It consults the index only to answer a
|
||||
# per-member question — does this named path have tracked files beneath it, i.e. is it a
|
||||
# DIRECTORY — because `<file>/**` and the pathspec `<file>` select different sets while spelling
|
||||
# the same string. Reviewed 2026-08-30.
|
||||
"test_ci_image_paths_pin_agreement.py": "index used for a per-member directory test, not a population",
|
||||
}
|
||||
|
||||
_HELPER = "scripts.tests.tracked_files"
|
||||
@@ -216,6 +222,47 @@ def test_the_primitive_does_not_recurse_into_an_untracked_subdirectory(tmp_path,
|
||||
)
|
||||
|
||||
|
||||
def test_the_nested_primitive_separates_a_DIRECTORY_from_a_FILE_and_a_PREFIX(tmp_path, monkeypatch):
|
||||
"""`tracked_under` decides directory-ness, and two neighbours are what make that non-trivial.
|
||||
|
||||
Its caller (`test_ci_image_paths_pin_agreement.py`) uses an empty result to mean "this path is a
|
||||
file, not a directory", because `<file>/**` matches nothing while the git pathspec `<file>` tracks
|
||||
the file — two spellings that look equal and select differently. A prefix sibling is the other
|
||||
trap: `docker/ci` must not collect `docker/cix/...`, or a file-only path would look like a
|
||||
directory because of its neighbour's name.
|
||||
"""
|
||||
repo = tmp_path / "repo"
|
||||
(repo / "docker" / "ci").mkdir(parents=True)
|
||||
(repo / "docker" / "cix").mkdir(parents=True)
|
||||
(repo / "docker" / "ci" / "Dockerfile").write_text("FROM scratch\n")
|
||||
(repo / "docker" / "cix" / "Dockerfile").write_text("FROM scratch\n")
|
||||
(repo / "docker" / "standalone.txt").write_text("a file, not a directory\n")
|
||||
|
||||
def git(*args):
|
||||
subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True)
|
||||
|
||||
git("init", "-q")
|
||||
git("config", "user.email", "guard@example.invalid")
|
||||
git("config", "user.name", "guard")
|
||||
git("add", "-A")
|
||||
git("commit", "-qm", "a directory, a prefix sibling, and a plain file")
|
||||
|
||||
monkeypatch.setattr(tracked_files, "REPO_ROOT", repo)
|
||||
|
||||
assert tracked_files.tracked_under("docker/ci") == {"docker/ci/Dockerfile"}, (
|
||||
"the prefix sibling docker/cix/ leaked into docker/ci's subtree, so a path would be judged a "
|
||||
"directory because of what its NEIGHBOUR is named"
|
||||
)
|
||||
assert tracked_files.tracked_under("docker/ci/") == {"docker/ci/Dockerfile"}, (
|
||||
"a trailing slash changed the answer; it is the same directory"
|
||||
)
|
||||
assert tracked_files.tracked_under("docker/standalone.txt") == set(), (
|
||||
"a tracked FILE reported a subtree, which is the emptiness its caller reads as 'this is a "
|
||||
"directory' — the `<file>/**` vs `<file>` false green"
|
||||
)
|
||||
assert tracked_files.tracked_under("docker/absent") == set(), "an absent path reported a subtree"
|
||||
|
||||
|
||||
def test_an_empty_index_FAILS_LOUDLY_rather_than_reporting_an_empty_population(tmp_path, monkeypatch):
|
||||
"""The floor under every floor. A silent empty population is how a completeness guard reports
|
||||
total coverage having examined nothing, which is the failure mode this repo has shipped twice
|
||||
@@ -577,8 +624,9 @@ def test_every_index_derived_module_is_registered():
|
||||
WHAT NO ASSERTION CAN DECIDE, dated so it is re-examined rather than assumed: whether an
|
||||
exemption is still WARRANTED. A stale key and an unexplained one are both caught below, but an
|
||||
exempt module that later grows a real derived population stays uncovered and silent. Reviewed
|
||||
2026-08-22 — the single entry uses the helper only to assemble a tmp fixture copy and asserts
|
||||
nothing about membership.
|
||||
2026-08-30 — two entries. One uses the helper only to assemble a tmp fixture copy; the other
|
||||
consults the index per-member, to decide whether a named path is a directory, and derives its
|
||||
population from parsed workflow documents instead. Neither asserts a file-population claim.
|
||||
"""
|
||||
exempt = {Path(__file__).name} | set(POPULATION_EXEMPT)
|
||||
importers = _modules_importing_the_helper()
|
||||
|
||||
@@ -102,6 +102,18 @@ def tracked_children(directory: str, patterns: tuple[str, ...]) -> set[str]:
|
||||
return found
|
||||
|
||||
|
||||
def tracked_under(directory: str) -> set[str]:
|
||||
"""Tracked files anywhere BENEATH `directory`, recursively.
|
||||
|
||||
This is the explicit nested ask `tracked_children` directs callers to when direct children are
|
||||
not enough. It decides whether a path names a directory at all: a path with nothing beneath it
|
||||
is a file or absent, and a caller that assumed a directory would otherwise compare two spellings
|
||||
that select different sets.
|
||||
"""
|
||||
prefix = directory.rstrip("/") + "/"
|
||||
return {path for path in _git_ls_files() if path.startswith(prefix)}
|
||||
|
||||
|
||||
def tracked_paths(directory: str, patterns: tuple[str, ...]) -> list[Path]:
|
||||
"""`tracked_children` as absolute `Path`s, sorted — for guards that read member contents.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user