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
221 lines
8.3 KiB
Bash
Executable file
221 lines
8.3 KiB
Bash
Executable file
#!/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[@]}"
|