|
|
|
@@ -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"
|
|
|
|
|
)
|