Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 26s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m46s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m4s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m50s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m16s
`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 own in-tree compiler (`modules/actions/workflowpattern` -> `modules/glob.CompileWorkflow`) 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; refuses a `<<` token on a code line (a herestring excluded) and a second bare `--`; and treats an absent and an empty `paths:` alike, because Gitea's `Skip` returns false on an empty sequence, so `paths: []` filters nothing and every push publishes. 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 path below `<dir>` contains a newline is matched by the git pathspec but not by the publish pattern. Verified by nine independent cold-review rounds, none of which found a false green; the last fuzzed 27,720 publish/pathspec pairs against a port of the deployed compiler and real `git ls-files`. fixes #855
133 lines
6.9 KiB
Python
133 lines
6.9 KiB
Python
"""The authoritative population for a guard whose members are FILES: the git index (ersatztv#806).
|
|
|
|
`testing.guard-derives-population-from-source` (#774) says a completeness guard derives its
|
|
population from a machine-readable authoritative source, and its worked examples are an enum and the
|
|
generated OpenAPI document. It does not say what to do when the population is *files*, and every
|
|
guard in this repo answered that with a filesystem walk. **A filesystem walk is not an authoritative
|
|
source.** It reports build output, editor droppings and whatever else happens to be on disk, and it
|
|
differs per machine, so a guard derived from it asserts a different population in CI than on the
|
|
laptop of the person it is supposed to stop.
|
|
|
|
#778 got that wrong three times in one PR, each time with an argument for why the traversal
|
|
sufficed, and the third shape is the one that motivates this module:
|
|
|
|
* a **content filter** on outbound-network tokens that omitted `git fetch`, making a hook that
|
|
fetches `origin/main` and derives a push decision structurally invisible;
|
|
* a **non-recursive `Path.glob`**, missing four nested files, one of which calls a live ErsatzTV
|
|
API and acts on the reply;
|
|
* **`Path.rglob`**, which then enumerated `.husky/_/` — 17 husky shims generated by `npm ci` via
|
|
`web/package.json`'s `prepare` script, gitignored (`.husky/_/.gitignore` is `*`) and untracked.
|
|
That made the guard **RED on every developer checkout and GREEN in CI**, whose `script-tests`
|
|
job checks out and pip-installs but never runs `npm ci`. A guard that fails everywhere except
|
|
where it runs teaches its readers to ignore it.
|
|
|
|
The index holds the same set of files every checkout receives from a clone, and excludes untracked
|
|
generated files **by construction** rather than by an exclusion list somebody has to maintain and
|
|
keep correct. It is not immutable and it is per-worktree; the claim is not that it never changes,
|
|
but that it changes only through a deliberate git operation — staging, a checkout, a reset, a
|
|
merge — whereas the disk changes whenever a build runs.
|
|
Note what that buys over `.gitignore`-awareness: `.husky/_/` happens to carry its own `.gitignore`,
|
|
but a stray untracked `foo.sh` in `.claude/hooks/` carries nothing, and only the index knows it is
|
|
not part of the repo.
|
|
|
|
**This is not "replace every glob".** The question per guard is whether it makes a COMPLETENESS
|
|
claim over tracked files. If it does, the index is the authoritative source. If it does not — a
|
|
fixture copying files into a tmp tree, a walk selecting the SUBJECT of a per-member property — say
|
|
so in the guard and leave it, per the boundary #774 already draws between scope and population.
|
|
|
|
`git ls-files` lists INDEX entries, so a file deleted in the working tree but not yet staged is
|
|
still reported. That is deliberate: callers that read member contents assert existence with their
|
|
own message rather than filtering, because filtering is what makes a missing member unrepresentable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fnmatch
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def _git_ls_files() -> list[str]:
|
|
"""Every path git tracks, as repo-relative posix strings.
|
|
|
|
The single place this package shells out to git, so a regression proof can narrow the tracked
|
|
set once and have every derivation built on it react.
|
|
|
|
Fails LOUDLY on an empty result rather than returning it: an empty population makes every
|
|
completeness assertion downstream pass vacuously, which is the exact failure these guards exist
|
|
to prevent.
|
|
"""
|
|
proc = subprocess.run(
|
|
["git", "-C", str(REPO_ROOT), "ls-files", "-z"],
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
# `check=True` would raise "returned non-zero exit status 128" and leave git's own diagnostic
|
|
# trapped in `e.stderr`. The two real triggers — a tree that is not a repository, and CI's
|
|
# `detected dubious ownership` — are both diagnosable ONLY from that text, and every module
|
|
# that imports this one would otherwise die with the same inscrutable line.
|
|
assert proc.returncode == 0, (
|
|
f"`git ls-files` failed in {REPO_ROOT} (exit {proc.returncode}). Every file population here "
|
|
f"comes from the index, so this is fatal rather than empty. git said:\n"
|
|
f"{proc.stderr.decode(errors='replace').strip() or '(no stderr)'}"
|
|
)
|
|
paths = [p for p in proc.stdout.decode().split("\0") if p]
|
|
assert paths, (
|
|
f"`git ls-files` reported nothing under {REPO_ROOT} — the derivation is broken, not the "
|
|
"repo. Every file population built on it would be empty and every completeness assertion "
|
|
"would pass vacuously."
|
|
)
|
|
return paths
|
|
|
|
|
|
def tracked_children(directory: str, patterns: tuple[str, ...]) -> set[str]:
|
|
"""Tracked files that are DIRECT children of `directory` and match one of `patterns`.
|
|
|
|
Direct children only, and that is the point rather than a limitation: every population here is
|
|
a flat directory (`.claude/hooks/*.sh`, `.husky/*`, `.gitea/workflows/*.yml`,
|
|
`scripts/tests/test_*.py`), and recursing is what dragged `.husky/_/` in. A guard that genuinely
|
|
needs a nested population should say so and ask for it explicitly.
|
|
|
|
Returns repo-relative posix paths, matching what the guards' inventories and error messages use.
|
|
"""
|
|
found: set[str] = set()
|
|
for path in _git_ls_files():
|
|
parent, _, name = path.rpartition("/")
|
|
if parent != directory:
|
|
continue
|
|
if any(fnmatch.fnmatch(name, pattern) for pattern in patterns):
|
|
found.add(path)
|
|
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.
|
|
|
|
Existence is ASSERTED, never filtered: a path in the index with no file on disk means the tree
|
|
is mid-edit, and reporting that is strictly better than silently shrinking the population, which
|
|
is the defect this module exists to remove.
|
|
"""
|
|
paths = []
|
|
for rel in sorted(tracked_children(directory, patterns)):
|
|
absolute = REPO_ROOT / rel
|
|
assert absolute.is_file(), (
|
|
f"git tracks {rel} but there is no file there. The population comes from the index, so "
|
|
"a working tree mid-delete is reported rather than silently shrinking the population."
|
|
)
|
|
paths.append(absolute)
|
|
return paths
|