Merge pull request #277 from cndgrr/build/251-vendored-manifest

feat(guards): the tag declares its vendorable set
This commit is contained in:
Daniel Marin 2026-08-04 11:52:15 +01:00 committed by GitHub
commit 017c571438
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 535 additions and 5 deletions

234
.github/scripts/vendored-check.sh vendored Executable file
View file

@ -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[@]}"

View file

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

14
changelog.d/251.md Normal file
View file

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

View file

@ -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/<pinned-tag>/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

View file

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

243
test/vendored.test.sh Normal file
View file

@ -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 <name> <manifest-entry...> — 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 <tree> <path> [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 <name> — 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