diff --git a/.github/scripts/vendored-check.sh b/.github/scripts/vendored-check.sh new file mode 100755 index 0000000..929e4fe --- /dev/null +++ b/.github/scripts/vendored-check.sh @@ -0,0 +1,234 @@ +#!/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. +# +# When it cannot bind, SAY SO. This guard's whole argument is that a silent +# miss is worse than a loud one, and a guard that quietly stops asserting one +# of its four properties is exactly that shape — so the skip is announced on +# every run, green or red, rather than inferred from the absence of a +# refusal (#251 round 1). +tracked_check=no +tracked_note="tracked-ness NOT asserted: $tree is not a git work tree root, so + 'is this file in the tag's tree' cannot be answered about THIS tree. The + other three manifest assertions (regular file, non-empty, no + symlink/dir/escape) still bind." +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 + tracked_note="" + 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[@]}" + [ -z "$tracked_note" ] || printf 'vendored-check: %s\n' "$tracked_note" + } >&2 + exit 1 +fi + +printf 'vendored-check: %d manifest entries resolve; %d root docs vendored, %d exempt.\n' \ + "${#manifest[@]}" "${#vendored[@]}" "${#exempted[@]}" +[ -z "$tracked_note" ] || printf 'vendored-check: %s\n' "$tracked_note" +[ "${#vendored[@]}" -eq 0 ] || printf ' vendored: %s\n' "${vendored[@]}" +[ "${#exempted[@]}" -eq 0 ] || printf ' exempt: %s\n' "${exempted[@]}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c17c424..e082bb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,11 @@ jobs: # Five stale markers survived the tags that shipped their machinery # (#221); #238 makes the release candidate reject that drift. run: bash .github/scripts/marker-check.sh + - name: Vendored manifest + # The manifest rules (issue #251; #248's near-miss): a doctrine file + # at the root that nobody added to docs/VENDORED.txt is invisible to + # every consumer's docs-sync, so it fails CI here instead. + run: bash .github/scripts/vendored-check.sh - name: Tests env: # The npm-backed version_write case may skip locally when npm is diff --git a/changelog.d/251.md b/changelog.d/251.md new file mode 100644 index 0000000..7853b80 --- /dev/null +++ b/changelog.d/251.md @@ -0,0 +1,14 @@ +### Added + +- CI now refuses a root `*.md` declared in neither `docs/VENDORED.txt` nor the + guard's short exemption list, so a new doctrine file can no longer reach a + tag undeclared and stay invisible to every consumer's `docs-sync` (#251). +- The same guard reads the manifest the other way: every entry must resolve to + a regular, non-empty, tracked file — no symlink, no directory, no `../` + escape (#251). + +### Changed + +- Consumer guidance: re-vendor tooling reads the pin's `docs/VENDORED.txt`, + never a hardcoded list, so a new doctrine file propagates at the next + ordinary pin bump with zero list edits (#251). diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 413385f..bd68963 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -598,6 +598,38 @@ It is **unreleased** (#248) until that tag exists: consumers add `.ceremony/RELEASES.md` only with the ordinary pin bump and re-sync, never by copying it ahead of their pinned doctrine set. +### Read the manifest, never a copy of it + +Anything on the consumer's side that needs to know *which* documents are +vendored — a re-vendor script, a `docs-sync` equivalent, the task list of a +conversion issue — reads **the pin's `docs/VENDORED.txt`** and never names +the files itself. The manifest is available at the pinned ref from `0.1.0` +and later — it shipped with `actions/docs-sync` itself (ceremony#19), in the +same commit, and that tool has read it rather than a list since — and it is +one path per line, relative to ceremony's root, blank lines ignored: + +```sh +# the vendored doc set at the ref this repo is pinned to +curl -fsSL "https://raw.githubusercontent.com/heavy-duty/ceremony//docs/VENDORED.txt" +``` + +That is the whole benefit: a doctrine file added in ceremony — `RELEASES.md` +was the last, ceremony#248 — reaches every consumer at its next **ordinary +pin bump**, with **zero list edits** anywhere. A hardcoded list propagates +nothing, and its staleness is silent rather than red: `docs-sync --check` +asserts byte-identity for the files the list names and says nothing at all +about one it omits, so a consumer keeps a green guard while governing +itself with doctrine it no longer has. + +What makes reading the manifest *sufficient* — rather than merely better +than a copy — is that ceremony's CI now refuses a root doctrine file that is +declared in neither the manifest nor a short in-script exemption list +(`.github/scripts/vendored-check.sh`), so the manifest at a tag is the +complete set as of that tag. That guarantee is **unreleased** (#251) until +the first tag carrying it exists; the manifest is worth reading at every +earlier pin regardless, since it is what `actions/docs-sync` has always +mirrored. + The consumer's ci.yml gains the guard alongside the others: ```yaml diff --git a/test/docs-sync.test.sh b/test/docs-sync.test.sh index f5ecae9..ff0f5b0 100644 --- a/test/docs-sync.test.sh +++ b/test/docs-sync.test.sh @@ -18,11 +18,13 @@ SCRIPT="$ROOT/actions/docs-sync/docs-sync.sh" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT -# RELEASES.md's consumer-availability promise is true only when the real -# manifest carries it (#248's review round). The fixture cases below prove -# manifest-driven behavior; this row binds that behavior to the promised file. -check "real manifest includes the release doctrine" 0 "RELEASES.md" \ - grep -Fx RELEASES.md "$ROOT/docs/VENDORED.txt" +# The real manifest is asserted by test/vendored.test.sh, not here (#251 D4). +# A `grep -Fx RELEASES.md` row lived at this spot from #248's review round, +# binding the promise to the one file that had nearly been missed. It was the +# hardcoded list the manifest exists to abolish, one layer down: two spellings +# of "the manifest is right" is exactly the drift it prevents. Its intent — +# every root doctrine file is declared, RELEASES.md included — is now a +# closed-world guard case, which the next file inherits for free. # --- fixture builders -------------------------------------------------------- diff --git a/test/vendored.test.sh b/test/vendored.test.sh new file mode 100644 index 0000000..50ee65d --- /dev/null +++ b/test/vendored.test.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +# Contract tests for .github/scripts/vendored-check.sh (issue #251) — the +# self-guard that makes docs/VENDORED.txt authoritative over ceremony's OWN +# tree. Driven against constructed fixture trees plus the real one; the CI +# step runs the same script against the real tree. +# +# The fixture doc set is deliberately NOT ceremony's real six: a guard that +# hardcodes the vendored list instead of reading the manifest fails these +# rows, which is the failure the whole issue is about. +# +# set -u, not -e: failing commands are behavior for the harness to inspect. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=test/harness.sh +. "$ROOT/test/harness.sh" + +CHECK="$ROOT/.github/scripts/vendored-check.sh" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# --- fixture builders -------------------------------------------------------- + +# tree — a fixture tree carrying only the manifest. +tree() { + local dir="$TMP/$1" + shift + rm -rf "$dir" + mkdir -p "$dir/docs" + printf '%s\n' "$@" >"$dir/docs/VENDORED.txt" +} + +# doc [content] — a regular file in a fixture tree. +doc() { + local path="$TMP/$1/$2" + mkdir -p "$(dirname "$path")" + printf '%s\n' "${3:-# a doc}" >"$path" +} + +# real_copy — the real tree reduced to what the guard reads: the +# manifest, every path the manifest names, and every root *.md. Cases mutate +# the copy, so the guard's verdict on ceremony's actual doc set is proven +# without touching the working tree. +real_copy() { + local dir="$TMP/$1" entry + rm -rf "$dir" + mkdir -p "$dir/docs" + cp "$ROOT/docs/VENDORED.txt" "$dir/docs/VENDORED.txt" + cp "$ROOT"/*.md "$dir/" + while IFS= read -r entry; do + [ -n "$entry" ] || continue + mkdir -p "$dir/$(dirname "$entry")" + cp "$ROOT/$entry" "$dir/$entry" + done <"$ROOT/docs/VENDORED.txt" +} + +run_check() { + bash "$CHECK" "$TMP/$1" +} + +# --- the happy tree ---------------------------------------------------------- + +# One manifest entry lives in a subdirectory: the manifest is PATHS, not +# filenames (docs-sync's fixtures prove the same), and the closed-world rule +# over the root must not regress that to root-only. +tree ok AGENTS.md RULES.md guide/DEEP.md +doc ok AGENTS.md +doc ok RULES.md +doc ok guide/DEEP.md +check "a tree whose root docs are all declared passes" 0 "3 manifest entries resolve" \ + run_check ok + +tree blanks AGENTS.md '' RULES.md +doc blanks AGENTS.md +doc blanks RULES.md +check "blank manifest lines are skipped, as docs-sync skips them" 0 "2 manifest entries" \ + run_check blanks + +# --- the closed world: a root doc in neither list ---------------------------- + +# The #248 near-miss, replayed as a test: a new doctrine file lands at the +# root and nobody adds it to the manifest. +tree newdoc AGENTS.md +doc newdoc AGENTS.md +doc newdoc NEWDOC.md +check "a root doc in neither list reds" 1 "'NEWDOC.md' is a root doc in NEITHER list" \ + run_check newdoc +check "...and the refusal names the manifest fix" 1 "add 'NEWDOC.md' to docs/VENDORED.txt" \ + run_check newdoc +check "...and the refusal names the exemption fix" 1 "add 'NEWDOC.md' to the exemption list" \ + run_check newdoc + +# The decision the guard forces, taken each way: vendor it… +tree newdoc-vendored AGENTS.md NEWDOC.md +doc newdoc-vendored AGENTS.md +doc newdoc-vendored NEWDOC.md +check "a root doc added to the manifest passes" 0 "2 manifest entries" \ + run_check newdoc-vendored + +# …or exempt it. The exemption list is in the script and carries a reason; +# README.md is one of the four ceremony-only root docs it names. +tree exempted AGENTS.md +doc exempted AGENTS.md +doc exempted README.md +check "a root doc on the exemption list passes, with its reason" 0 "exempt: README.md" \ + run_check exempted + +# The exemption list is NEVER read from prose. CONTRIBUTING.md's vendored-set +# sentence is documentation; two declarations of the same set is the drift +# the manifest exists to prevent (#251 D2's second must-fail). +tree prose AGENTS.md +doc prose AGENTS.md +doc prose EXTRA.md +doc prose CONTRIBUTING.md "The vendored set is AGENTS.md and EXTRA.md." +check "a doc declared only in prose still reds" 1 "'EXTRA.md' is a root doc in NEITHER list" \ + run_check prose + +# --- the rule is ROOT-level only --------------------------------------------- + +# A guard that walked the tree would need an exemption list long enough that +# nobody reads it — the exact failure the root-only rule is shaped against. +# So an undeclared *.md under docs/, actions/ or drills/ must stay GREEN. +tree subdirs AGENTS.md +doc subdirs AGENTS.md +doc subdirs docs/CONSUMERS.md +doc subdirs actions/thing/README.md +doc subdirs drills/2026-07-01.md +check "undeclared *.md below the root stays green (no recursion)" 0 "1 manifest entries" \ + run_check subdirs + +# --- manifest → tree: the scan ----------------------------------------------- + +tree missing AGENTS.md GONE.md +doc missing AGENTS.md +check "a manifest entry with no file reds, naming it" 1 "names 'GONE.md' but the tree has no such file" \ + run_check missing + +tree symlinked AGENTS.md LINK.md +doc symlinked AGENTS.md +ln -s AGENTS.md "$TMP/symlinked/LINK.md" +check "a manifest entry pointing at a symlink reds" 1 "names 'LINK.md', which is a SYMLINK" \ + run_check symlinked + +tree dir-entry AGENTS.md guide +doc dir-entry AGENTS.md +mkdir -p "$TMP/dir-entry/guide" +check "a manifest entry pointing at a directory reds" 1 "names 'guide', which is a DIRECTORY" \ + run_check dir-entry + +tree empty-entry AGENTS.md HOLLOW.md +doc empty-entry AGENTS.md +: >"$TMP/empty-entry/HOLLOW.md" +check "a manifest entry pointing at an empty file reds" 1 "names 'HOLLOW.md', which is EMPTY" \ + run_check empty-entry + +# The escape case exists as a docs-sync fixture; ceremony's own manifest must +# not be the one place it goes unchecked. +tree escape AGENTS.md ../outside.md +doc escape AGENTS.md +check "a manifest entry escaping with ../ reds" 1 "names '../outside.md'" \ + run_check escape + +tree absolute AGENTS.md /etc/hosts +doc absolute AGENTS.md +check "an absolute manifest entry reds" 1 "names '/etc/hosts'" \ + run_check absolute + +# --- the manifest itself ----------------------------------------------------- + +rm -rf "$TMP/no-manifest" +mkdir -p "$TMP/no-manifest" +check "a tree with no manifest reds" 1 "no docs/VENDORED.txt under" \ + run_check no-manifest + +rm -rf "$TMP/empty-manifest" +mkdir -p "$TMP/empty-manifest/docs" +: >"$TMP/empty-manifest/docs/VENDORED.txt" +check "an empty manifest reds" 1 "is empty" run_check empty-manifest + +# --- tracked-ness ------------------------------------------------------------ + +# A file present on this machine but absent from the tag's tree cannot be +# fetched by a consumer syncing at that tag. The assertion binds only where +# it can: when the tree IS a git work tree root. +tree tracked AGENTS.md RULES.md +doc tracked AGENTS.md +doc tracked RULES.md +git init -q "$TMP/tracked" +git -C "$TMP/tracked" add docs/VENDORED.txt AGENTS.md RULES.md +check "a git tree whose manifest entries are all tracked passes" 0 "2 manifest entries" \ + run_check tracked + +tree untracked AGENTS.md RULES.md +doc untracked AGENTS.md +doc untracked RULES.md +git init -q "$TMP/untracked" +git -C "$TMP/untracked" add docs/VENDORED.txt AGENTS.md +check "a git tree with an untracked manifest entry reds" 1 "names 'RULES.md', which is not TRACKED" \ + run_check untracked + +# ...and where it cannot bind, the skip ANNOUNCES ITSELF rather than being +# inferred from the absence of a refusal (#251 round 1). A guard that quietly +# stops asserting one of its four properties is the silent miss this whole +# script argues against, so the degradation is visible on both output paths. +check "a non-git tree says tracked-ness was not asserted" 0 "tracked-ness NOT asserted" \ + run_check ok +check "...and says it on the red path too, beside the refusals" 1 "tracked-ness NOT asserted" \ + run_check newdoc + +# The converse, so the note is not simply always printed: where the tree IS a +# git work tree root the assertion bound, and nothing is announced. +no_skip_note() { ! run_check tracked 2>&1 | grep -qF "tracked-ness NOT asserted"; } +check "a git work tree root announces no skip — the assertion bound" 0 "" \ + no_skip_note + +# --- the real tree ----------------------------------------------------------- + +check "this tree, unmodified, is green" 0 "manifest entries resolve" bash "$CHECK" "$ROOT" + +# The #248 near-miss on the REAL doc set: a scratch root doc nobody declared. +real_copy scratch +doc scratch SCRATCHDOC.md +check "a scratch root doc on the real tree reds, naming it" 1 "'SCRATCHDOC.md' is a root doc in NEITHER list" \ + run_check scratch + +# RELEASES.md stays listed — the regression criterion #248's review round +# bought, now asserted BY THE GUARD rather than by a hardcoded `grep -Fx` row +# in test/docs-sync.test.sh (#251 D1, D4). The closed world holds in both +# directions: dropping it from the manifest alone reds… +real_copy releases-dropped +grep -v '^RELEASES\.md$' "$ROOT/docs/VENDORED.txt" >"$TMP/releases-dropped/docs/VENDORED.txt" +check "dropping RELEASES.md from the manifest alone reds" 1 "'RELEASES.md' is a root doc in NEITHER list" \ + run_check releases-dropped + +# …and it is green only when the file leaves the root in the same breath. +real_copy releases-gone +grep -v '^RELEASES\.md$' "$ROOT/docs/VENDORED.txt" >"$TMP/releases-gone/docs/VENDORED.txt" +rm -f "$TMP/releases-gone/RELEASES.md" +check "dropping RELEASES.md from the manifest AND the root is green" 0 "manifest entries resolve" \ + run_check releases-gone + +summary