From 0760d0c21f506a24084d23de53b4d11e9583dd00 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:59:18 +0000 Subject: [PATCH] feat(guards): the vendored manifest guards ceremony own tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/VENDORED.txt is already machine-authoritative on the consumer side — actions/docs-sync reads it as the sole declaration and enforces "manifest union .ceremony/, nothing else". Nothing enforced the other end: a doctrine file landing at ceremony every root with nobody adding it to the manifest is a SILENT miss, because docs-sync only asserts byte-identity for the files the manifest names. Two directions, two mechanisms (#251 D2): manifest -> tree is a scan (regular, non-empty, tracked, no symlink, no directory, no .. escape); tree -> manifest is a closed-world rule over root *.md with a short in-script exemption list, because nothing in the tree answers "which files are vendorable". Refs #251 --- .github/scripts/vendored-check.sh | 221 ++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100755 .github/scripts/vendored-check.sh diff --git a/.github/scripts/vendored-check.sh b/.github/scripts/vendored-check.sh new file mode 100755 index 0000000..126c067 --- /dev/null +++ b/.github/scripts/vendored-check.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +# The vendored-manifest self-guard (issue #251; #248's near-miss). Consumers +# mirror the agent-facing doc set declared by docs/VENDORED.txt, and +# actions/docs-sync already enforces "manifest ∪ .ceremony/, nothing else" +# on the CONSUMER side. Nothing enforced the other end: a new doctrine file +# could land at ceremony's root and nobody add it to the manifest, and the +# miss is SILENT — docs-sync asserts byte-identity for the files the +# manifest names, so a doc it omits is never checked and every consumer +# drifts doctrine-blind with green guards. #248 (RELEASES.md) nearly shipped +# that way, caught only by a hand-written `grep -Fx RELEASES.md` row in +# test/docs-sync.test.sh — the hardcoded list this guard abolishes, one +# layer down. That row is deleted; this script carries its intent. +# +# Two directions, two mechanisms, because only one of them can be a scan +# (#251 D2): +# +# * MANIFEST → TREE is a scan: every entry resolves to a regular, +# non-empty, tracked file at the declared path — no symlink (PR #43: +# a symlink read as doctrine while staying invisible), no directory, +# no `../` escape. +# * TREE → MANIFEST cannot scan, because nothing in the tree answers +# "which files are vendorable" — the manifest is the only +# machine-readable notion of it. So it gets a CLOSED-WORLD RULE +# instead: every `*.md` at the repository ROOT is either in the +# manifest or in the exemption list below. Adding a root doc then +# forces a one-line decision — vendor it or exempt it — and the +# refusal names the file and both fixes. +# +# The rule is ROOT-LEVEL `*.md` ONLY. It does not walk docs/, actions/ or +# drills/: those hold no agent-facing doctrine, and a recursive version +# would grow the exemption list past the length at which a reviewer still +# reads it — which is the failure this rule is shaped against. +# +# The exemption list lives HERE, in the script. CONTRIBUTING.md's +# vendored-set sentence is documentation, never an input: two declarations +# of the same set is the drift the manifest exists to prevent. +# +# Usage: vendored-check.sh [tree-dir] (default: the repo root — the CI +# step; tests point it at fixture trees) +set -euo pipefail + +MANIFEST="docs/VENDORED.txt" + +tree="${1:-.}" + +# The root docs that are deliberately ceremony-only. Each carries the reason +# it is not vendored, because the reason is what lets the next reviewer +# judge the next addition. Prints the reason and returns 0 when exempt. +exempt_reason() { + case "$1" in + README.md) + echo "ceremony's own front page — a consumer's router is AGENTS.md, not this repo's README" + ;; + CONTRIBUTING.md) + echo "repo-specific facts (this repo's roster, scopes and conventions); every governed repo writes its own" + ;; + CHANGELOG.md) + echo "ceremony's own release history; a consumer keeps its own" + ;; + FLEET.md) + echo "the operator's fleet map — about running the fleet, not about how a governed repo works" + ;; + *) return 1 ;; + esac +} + +die() { + printf 'vendored-check: %s\n' "$@" >&2 + exit 1 +} + +manifest_file="$tree/$MANIFEST" +[ -f "$manifest_file" ] || die \ + "no $MANIFEST under $tree — the manifest is the sole declaration of the" \ + " vendored doc set, and this guard has nothing to guard without it." + +# Blank lines are skipped, exactly as actions/docs-sync reads it: the guard +# and the tool must accept the same file, or one of them is the bug. +mapfile -t manifest < <(grep -v '^[[:space:]]*$' "$manifest_file" || true) +[ "${#manifest[@]}" -gt 0 ] || die \ + "$MANIFEST is empty — an empty doctrine set is a ceremony bug, not a repo" \ + " with no rules." + +# Whether the tracked-file assertion can bind: only when the tree IS a git +# work tree root. Fixture trees are plain directories, and asserting +# tracked-ness against an enclosing repository would be asserting about the +# wrong tree. +tracked_check=no +if command -v git >/dev/null 2>&1; then + toplevel="$(git -C "$tree" rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$toplevel" ] && [ "$toplevel" = "$(cd "$tree" && pwd -P)" ]; then + tracked_check=yes + fi +fi + +# Every refusal is collected and reported together, one multi-line string +# per offending file: a guard that stops at the first problem makes a +# builder pay one CI round per file. +problems=() + +# --- manifest → tree --------------------------------------------------------- + +for entry in "${manifest[@]}"; do + case "$entry" in + /* | *..*) + problems+=("$( + printf '%s\n' \ + "$MANIFEST names '$entry' — an absolute path or a '..' escape. The" \ + " mirror writes only inside a consumer's .ceremony/, so a path that" \ + " leaves it is never vendorable." \ + " Fix: name the path relative to the repository root, with no '..'." + )") + continue + ;; + esac + + path="$tree/$entry" + + # -L before -f: `[ -f ]` follows the link, so a symlink to a real file + # would otherwise pass as a regular one. + if [ -L "$path" ]; then + problems+=("$( + printf '%s\n' \ + "$MANIFEST names '$entry', which is a SYMLINK. A symlink vendors as" \ + " doctrine while its content lives somewhere the mirror never checks" \ + " (PR #43's round: it read as doctrine and stayed invisible)." \ + " Fix: make '$entry' a regular file, or drop the entry from $MANIFEST." + )") + continue + fi + if [ -d "$path" ]; then + problems+=("$( + printf '%s\n' \ + "$MANIFEST names '$entry', which is a DIRECTORY. The manifest declares" \ + " files, one per line — a directory entry vendors nothing." \ + " Fix: name each file under '$entry' on its own line, or drop the entry." + )") + continue + fi + if [ ! -f "$path" ]; then + problems+=("$( + printf '%s\n' \ + "$MANIFEST names '$entry' but the tree has no such file. Every consumer" \ + " mirroring this ref would fail on it." \ + " Fix: add '$entry' to the tree, or remove it from $MANIFEST." + )") + continue + fi + if [ ! -s "$path" ]; then + problems+=("$( + printf '%s\n' \ + "$MANIFEST names '$entry', which is EMPTY. An empty file vendors as" \ + " doctrine that says nothing, and is read as doctrine anyway." \ + " Fix: write '$entry', or remove it from $MANIFEST." + )") + continue + fi + if [ "$tracked_check" = yes ] && + ! git -C "$tree" ls-files --error-unmatch -- "$entry" >/dev/null 2>&1; then + problems+=("$( + printf '%s\n' \ + "$MANIFEST names '$entry', which is not TRACKED. A file absent from the" \ + " tag's tree cannot be fetched by a consumer syncing at that tag," \ + " however present it is on this machine." \ + " Fix: git add '$entry', or remove it from $MANIFEST." + )") + continue + fi +done + +# --- tree → manifest: the closed world over root `*.md` ---------------------- + +in_manifest() { + local p + for p in "${manifest[@]}"; do + [ "$p" = "$1" ] && return 0 + done + return 1 +} + +shopt -s nullglob +exempted=() +vendored=() +for path in "$tree"/*.md; do + doc="${path##*/}" + if in_manifest "$doc"; then + vendored+=("$doc") + continue + fi + if reason="$(exempt_reason "$doc")"; then + exempted+=("$doc — $reason") + continue + fi + problems+=("$( + printf '%s\n' \ + "'$doc' is a root doc in NEITHER list. Every root *.md is either vendored" \ + " doctrine — mirrored into every governed repo at .ceremony/ — or" \ + " deliberately ceremony-only, and nothing in the tree says which, so the" \ + " decision has to be written down. Fix, one of:" \ + " * add '$doc' to $MANIFEST, if it is agent-facing doctrine that every" \ + " governed repo must carry;" \ + " * add '$doc' to the exemption list in" \ + " .github/scripts/vendored-check.sh, with the reason it stays" \ + " ceremony-only." + )") +done +shopt -u nullglob + +if [ "${#problems[@]}" -gt 0 ]; then + { + printf 'vendored-check: %d problem(s) — docs/VENDORED.txt and the tree disagree.\n\n' \ + "${#problems[@]}" + printf '%s\n\n' "${problems[@]}" + } >&2 + exit 1 +fi + +printf 'vendored-check: %d manifest entries resolve; %d root docs vendored, %d exempt.\n' \ + "${#manifest[@]}" "${#vendored[@]}" "${#exempted[@]}" +[ "${#vendored[@]}" -eq 0 ] || printf ' vendored: %s\n' "${vendored[@]}" +[ "${#exempted[@]}" -eq 0 ] || printf ' exempt: %s\n' "${exempted[@]}"