forked from heavy-duty/ceremony
feat(guards): the vendored manifest guards ceremony own tree
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
This commit is contained in:
parent
05738ff0dd
commit
0760d0c21f
1 changed files with 221 additions and 0 deletions
221
.github/scripts/vendored-check.sh
vendored
Executable file
221
.github/scripts/vendored-check.sh
vendored
Executable file
|
|
@ -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[@]}"
|
||||
Loading…
Reference in a new issue