ceremony/actions/docs-sync/docs-sync.sh
cluade-reviewer-andresmgsl e0cd0cb7a3
All checks were successful
CI / test (pull_request) Successful in 1m31s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 24s
fix(docs-sync): the mirror is fetched from the forge in play, never a built-in one (#201)
heavy-duty/ceremony exists on two forges and the same ref names a different
tree on each — this forge's 0.4.1 carries lib/forge*.sh, GitHub's carries none
of it. The fetch URL was hard-coded to github.com, so a consumer's `.ceremony/`
mirror was verified against a tree it never pinned, and the fetch returned
HTTP 200 while doing it: --check reported drift the consumer could not fix,
and --fix would have rewritten a correct mirror into the wrong one.

The host now comes from GITHUB_SERVER_URL, which Actions injects on both
forges and which lib/forge.sh already selects the whole backend on. Unset,
with no --source, is a refusal naming the variable rather than a guess —
the same rule the pin itself has always followed.

The fetch path had no test coverage at all: every existing row passes
--source, which overrides the fetch entirely. It is now driven against a
PATH-stubbed curl that records the URL and serves a tarball, so the real tar
pipeline still runs and which forge a pin resolves against is asserted.

Refs #201
2026-08-05 11:16:23 +00:00

404 lines
17 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
set -euo pipefail
# docs-sync.sh [--check|--fix] [--source <dir>] — materialize and verify the
# `.ceremony/` doctrine mirror in a governed repo. Run from the consumer's
# repo root (the action runs after the consumer's own checkout, like every
# guard).
#
# WHY A COPY EXISTS AT ALL (the reference-vs-mirror rationale — issue #19,
# PR #17's consumption model): workflows and actions are consumed BY
# REFERENCE — GitHub fetches them from the pinned ref at run time, so no
# copy ever exists in a consumer, and nothing can drift. Documents have no
# such runtime. A document's only "runtime" is an agent reading the working
# tree of the repo it stands in, and a doc that requires a cross-repo fetch
# before it governs is a doc that sometimes goes unread. So the agent-facing
# set (the manifest, docs/VENDORED.txt) must exist IN each governed repo's
# tree. Anyone tempted to "simplify" `.ceremony/` back to a pointer at
# heavy-duty/ceremony is reinventing the sometimes-unread doc. The mirror is
# the fix, and this tool is what makes the mirror safe: machine-written
# (--fix), machine-verified (--check, in the consumer's CI on every PR), so
# drift is unrepresentable — the only kind of copy the org allows.
#
# ONE PIN GOVERNS MACHINERY AND DOCTRINE. The ref is read from the
# consumer's .github/workflows/release.yml — the single
# `uses: heavy-duty/ceremony/.github/workflows/release.yml@<ref>` line —
# never from an input or a second config file: a second pin is a second
# thing to bump, and two pins can disagree, which is exactly the drift this
# tool exists to make unrepresentable. Exactly one such line must match;
# zero or several is a refusal that names the file — this tool never
# guesses a ref. Commented-out lines do not count: ceremony's own
# release.yml carries the pin shape inside its header essay, and any
# consumer that pastes a documentation snippet into a comment would
# otherwise appear to have two pins.
#
# THE MIRROR IS EXACT — manifest `.ceremony/`, nothing else. A drifted
# file, a missing file, an extra file not in the manifest, or no
# `.ceremony/` at all each fails --check with a message naming the offender
# and the fix; --fix writes AND deletes (a manifest removal must remove the
# vendored copy — mirror means mirror, or "extra" files accumulate as
# unverified doctrine).
#
# AND THE MIRROR IS PLAIN FILES. A symlink committed anywhere this tool
# touches — a vendored path, a subdirectory, `.ceremony/` itself, the root
# AGENTS.md — redirects the tool outside the mirror: cp writes THROUGH the
# link (anywhere the CI token can reach), cmp reads through it and reports
# the target's bytes as the mirror's, and a `find -type f` scan skips link
# nodes entirely, so the stray poses as doctrine while staying invisible
# (PR #43's review round, both findings reproduced). So both modes refuse
# any non-regular node before touching anything: the fix for a symlink is
# a human deleting it, never a tool following it.
#
# TWO FILES ARE SPECIAL, both deliberately:
# * `.ceremony/README.md` is GENERATED here — the machine-managed marker
# plus where the pin lives — instead of per-file banners, so every
# vendored file stays byte-identical to its source and the check is a
# plain cmp, never a strip-the-banner parse. It is verified like
# everything else (against the generated text, not the source tree):
# the file that says "a hand edit goes red" must itself go red when
# hand-edited, or the marker is the one unverified spot in the mirror.
# * the consumer's ROOT AGENTS.md is scaffolded once by --fix and never
# overwritten. Agent harnesses auto-load root AGENTS.md (the cross-agent
# convention), so the stub is what makes "you are a reviewer here" a
# sufficient launch prompt — but it is per-repo content the moment the
# repo edits it, so --check asserts only that it exists.
#
# A file of its own so test/docs-sync.test.sh can drive both modes against
# constructed source and consumer trees. --source <dir> substitutes a local
# ceremony checkout for the tarball fetch: offline tests, and previewing a
# ceremony PR against a consumer before anything is released. The pin is
# still read and validated with --source — a consumer without its one pin
# line has nothing for the mirror to be verified against.
WORKFLOW=".github/workflows/release.yml"
MIRROR=".ceremony"
MANIFEST="docs/VENDORED.txt"
README_NAME="README.md"
die() {
printf '%s\n' "$@" >&2
exit 1
}
# Inputs arrive as env vars from action.yml (MODE, SOURCE) or as flags for
# local and test use; flags win.
mode="${MODE:-check}"
source_dir="${SOURCE:-}"
while [ $# -gt 0 ]; do
case "$1" in
--check) mode=check ;;
--fix) mode=fix ;;
--source)
[ $# -ge 2 ] || die "docs-sync: --source needs a directory"
source_dir="$2"
shift
;;
*) die "docs-sync: unknown argument: $1 (usage: docs-sync.sh [--check|--fix] [--source <dir>])" ;;
esac
shift
done
case "$mode" in
check | fix) ;;
*) die "docs-sync: unknown mode: '$mode' (check or fix)" ;;
esac
# --- the pin ----------------------------------------------------------------
[ -f "$WORKFLOW" ] || die \
"docs-sync: no $WORKFLOW — the pin lives there (the single" \
" 'uses: heavy-duty/ceremony/.github/workflows/release.yml@<ref>' line)." \
" Add the release caller before syncing doctrine: the mirror is verified" \
" against the pin, and without one there is nothing to verify against."
# A real `uses:` key only — a leading '#' anywhere before it is a comment
# and does not count (see the header: ceremony's own release.yml carries
# the shape in a comment).
mapfile -t pin_lines < <(grep -E \
'^[[:space:]]*(-[[:space:]]*)?uses:[[:space:]]*heavy-duty/ceremony/\.github/workflows/release\.yml@' \
"$WORKFLOW" || true)
case "${#pin_lines[@]}" in
0)
die "docs-sync: no pin line in $WORKFLOW — expected exactly one" \
" 'uses: heavy-duty/ceremony/.github/workflows/release.yml@<ref>'," \
" found none. This tool never guesses a ref."
;;
1) ;;
*)
die "docs-sync: ${#pin_lines[@]} pin lines in $WORKFLOW — exactly one" \
" 'uses: heavy-duty/ceremony/.github/workflows/release.yml@<ref>' must" \
" match, or 'the pin' is ambiguous. This tool never guesses a ref."
;;
esac
ref="$(printf '%s\n' "${pin_lines[0]}" | sed -E 's/^.*@//; s/[[:space:]#].*$//')"
[ -n "$ref" ] || die "docs-sync: the pin line in $WORKFLOW carries an empty ref after '@'"
# --- the source tree ----------------------------------------------------------
fetch_tmp=""
# An if, not `&&`: the trap's last status becomes the script's exit code,
# and a bare `[ -n ] && rm` returns 1 whenever there was nothing to clean —
# turning every --source success into a failure.
cleanup() { if [ -n "$fetch_tmp" ]; then rm -rf "$fetch_tmp"; fi; }
trap cleanup EXIT
if [ -n "$source_dir" ]; then
[ -d "$source_dir" ] || die "docs-sync: --source: no such directory: $source_dir"
src="$source_dir"
origin="$source_dir (--source override; pin is heavy-duty/ceremony@$ref)"
else
# The repo is public: a plain tarball fetch, no auth, no git. Works for a
# tag, a branch, or a commit SHA alike.
#
# THE FORGE COMES FROM THE ENVIRONMENT, NEVER FROM THIS FILE (#201).
# heavy-duty/ceremony exists on two forges and the same ref names a
# DIFFERENT TREE on each: `0.4.1` on this forge carries lib/forge.sh,
# lib/forge-github.sh and lib/forge-forgejo.sh; GitHub's `0.4.1` carries
# none of them. A hard-coded host therefore verified a consumer's mirror
# against a tree it never pinned — and did it with HTTP 200, so --check
# reported drift the consumer could not fix and --fix would have rewritten
# a correct mirror into the wrong one. The version numbers agreeing is the
# hazard, not the protection (#197 decision 2).
#
# GITHUB_SERVER_URL is what Actions injects on both forges, and
# lib/forge.sh already selects the whole backend on it — so a consumer run
# that reached this line has it. Unset means we do not know which forge the
# pin refers to, and guessing is what this issue is about: refuse instead,
# the same way the pin itself is never guessed.
[ -n "${GITHUB_SERVER_URL:-}" ] || die \
"docs-sync: GITHUB_SERVER_URL is unset, so the forge holding" \
" heavy-duty/ceremony@$ref is unknown — and the same ref names a" \
" different tree on each forge. Set it to the forge this consumer is" \
" pinned against, or pass --source <dir>. This tool never guesses a forge."
fetch_tmp="$(mktemp -d)"
url="${GITHUB_SERVER_URL%/}/heavy-duty/ceremony/archive/${ref}.tar.gz"
curl -fsSL "$url" | tar -xz --strip-components=1 -C "$fetch_tmp" || die \
"docs-sync: cannot fetch heavy-duty/ceremony@$ref ($url) —" \
" does the pinned ref exist on that forge?"
src="$fetch_tmp"
origin="heavy-duty/ceremony@$ref"
fi
# --- the manifest -------------------------------------------------------------
# The manifest is read from the SOURCE tree, not the consumer: what gets
# mirrored is decided at the pinned ref, so bumping the pin onto a ref that
# adds or drops a doc re-shapes the mirror in the same PR, with no second
# list to update. It is also the single source of the set — nothing below
# hardcodes a filename.
manifest_file="$src/$MANIFEST"
[ -f "$manifest_file" ] || die \
"docs-sync: no $MANIFEST in $origin — the manifest is the single source" \
" of what gets mirrored; a ref that predates it cannot govern a mirror." \
" Bump the pin to a ref that carries it."
mapfile -t manifest < <(grep -v '^[[:space:]]*$' "$manifest_file" || true)
[ "${#manifest[@]}" -gt 0 ] || die \
"docs-sync: $MANIFEST in $origin is empty — an empty doctrine set is a" \
" ceremony bug, not a repo with no rules; refusing to mirror it."
for f in "${manifest[@]}"; do
case "$f" in
/* | *..*)
die "docs-sync: refusing manifest path '$f' — the mirror writes only" \
" inside $MIRROR/, and that path escapes it."
;;
esac
[ -f "$src/$f" ] || die \
"docs-sync: the manifest names '$f' but $origin has no such file —" \
" fix $MANIFEST in heavy-duty/ceremony."
done
in_manifest() {
local p
for p in "${manifest[@]}"; do
[ "$p" = "$1" ] && return 0
done
return 1
}
# Every file physically present in the mirror, relative to it. sorted so
# messages come out in a stable order.
mirror_files() {
find "$MIRROR" -type f | LC_ALL=C sort | while IFS= read -r path; do
printf '%s\n' "${path#"$MIRROR"/}"
done
}
# --- the two generated texts ---------------------------------------------------
readme_content() {
cat <<'EOF'
# .ceremony/ — the vendored doctrine mirror
Machine-managed by heavy-duty/ceremony's `actions/docs-sync`. Never edit
these files here: they are byte-identical copies of
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony) at this
repository's pinned ref, and CI re-diffs them on every PR — a hand edit
goes red. They are changed in heavy-duty/ceremony, through its own flow,
and arrive here when the pin moves.
The pin lives in `.github/workflows/release.yml` — the single
`uses: heavy-duty/ceremony/.github/workflows/release.yml@<ref>` line. One
pin governs machinery and doctrine alike: bump it and re-sync this mirror
in the same PR (`docs-sync --fix`, or let the red check on the bump PR say
what is stale).
EOF
}
stub_content() {
cat <<'EOF'
# AGENTS.md — start at .ceremony/
This repository is governed by
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Read
`.ceremony/AGENTS.md` first — it routes you to your role file, vendored
beside it. Repo specifics (the review panel roster, the scope labels, what
a drill means here, code conventions) live in CONTRIBUTING.md.
EOF
}
# --- the mirror is plain files (see the header; PR #43's review round) ----------
# Refusals, not repairs, in BOTH modes — deliberately unlike drift, where
# --fix is the advertised cure: repairing a symlink means either deleting a
# node that points somewhere or writing through it, and a tool must do
# neither on its own. -L before -d/-f everywhere: the test that follows the
# link is exactly the bug.
guard_plain_tree() {
local offenders
if [ -L "$MIRROR" ]; then
die "docs-sync: $MIRROR is a symlink, not a directory — a linked mirror" \
" redirects every write outside the tree this tool is allowed to" \
" touch. Refusing both modes: delete the symlink, then re-run" \
" docs-sync --fix."
fi
if [ -d "$MIRROR" ]; then
offenders="$(find "$MIRROR" -mindepth 1 ! -type f ! -type d | LC_ALL=C sort)"
[ -z "$offenders" ] || die \
"docs-sync: non-regular node(s) in the mirror — a symlink (or fifo," \
" socket, …) under $MIRROR/ makes cp write and cmp read outside the" \
" mirror, and hides from the file scan. Refusing both modes; delete" \
" these by hand, then re-run docs-sync --fix:" \
"$offenders"
fi
if [ -L AGENTS.md ]; then
die "docs-sync: the root AGENTS.md is a symlink — the scaffold and the" \
" existence check must never resolve through a link (a dangling one" \
" would even make --fix write through it). Refusing both modes:" \
" replace the symlink with a regular file (or delete it and let" \
" docs-sync --fix scaffold the stub)."
fi
if [ -e AGENTS.md ] && [ ! -f AGENTS.md ]; then
die "docs-sync: the root AGENTS.md exists but is not a regular file —" \
" nothing this tool could do to it is right. Refusing both modes:" \
" remove it, then re-run docs-sync --fix to scaffold the stub."
fi
}
# --- check ----------------------------------------------------------------------
run_check() {
local failures=0 f rel
complain() {
printf '%s\n' "$@" >&2
failures=$((failures + 1))
}
if [ ! -d "$MIRROR" ]; then
complain "docs-sync: $MIRROR/ is missing entirely — this tree carries no" \
" doctrine mirror. Fix: run docs-sync --fix and commit the result."
else
for f in "${manifest[@]}"; do
if [ ! -f "$MIRROR/$f" ]; then
complain "docs-sync: $MIRROR/$f is missing from the mirror." \
" Fix: run docs-sync --fix."
elif ! cmp -s "$src/$f" "$MIRROR/$f"; then
complain "docs-sync: $MIRROR/$f has drifted from $origin." \
" Vendored files are never edited in place — they are changed in" \
" heavy-duty/ceremony, through its own flow. Fix: run docs-sync --fix" \
" (or re-run after bumping the pin, if the drift is a stale pin)."
fi
done
while IFS= read -r rel; do
[ "$rel" = "$README_NAME" ] && continue
in_manifest "$rel" && continue
complain "docs-sync: extra file in the mirror: $MIRROR/$rel — not in the" \
" manifest ($MANIFEST). The mirror is exact: everything under" \
" $MIRROR/ must be vendored and verified, or it poses as doctrine" \
" without being checked. Fix: run docs-sync --fix (it deletes orphans)."
done < <(mirror_files)
# The README is machine-written against generated text, so it is
# machine-verified against the same text — the marker that warns "a hand
# edit goes red" is not itself an unverified hole (kimi-bot, PR #43).
if [ ! -f "$MIRROR/$README_NAME" ]; then
complain "docs-sync: $MIRROR/$README_NAME is missing — the machine-managed" \
" marker is part of the mirror. Fix: run docs-sync --fix."
elif ! readme_content | cmp -s - "$MIRROR/$README_NAME"; then
complain "docs-sync: $MIRROR/$README_NAME has drifted from its generated" \
" content — the README is machine-written, and a hand edit here is" \
" exactly what its own text warns against. Fix: run docs-sync --fix."
fi
fi
# Existence only, content free: the stub is per-repo the moment the repo
# edits it (see the header).
[ -f AGENTS.md ] || complain \
"docs-sync: no root AGENTS.md — the stub that routes agents into" \
" $MIRROR/ is missing, so 'you are a reviewer here' has no entry point." \
" Fix: run docs-sync --fix (scaffolds it once; edit it freely after)."
[ "$failures" -eq 0 ] || die "docs-sync: $failures problem(s) — see above."
echo "docs-sync: $MIRROR/ is an exact mirror of $origin (${#manifest[@]} files)"
}
# --- fix ------------------------------------------------------------------------
run_fix() {
local changed=0 f rel dest
note() {
printf 'docs-sync: %s\n' "$1"
changed=$((changed + 1))
}
mkdir -p "$MIRROR"
for f in "${manifest[@]}"; do
dest="$MIRROR/$f"
if [ ! -f "$dest" ]; then
mkdir -p "$(dirname "$dest")"
cp "$src/$f" "$dest"
note "added $dest"
elif ! cmp -s "$src/$f" "$dest"; then
cp "$src/$f" "$dest"
note "updated $dest"
fi
done
while IFS= read -r rel; do
[ "$rel" = "$README_NAME" ] && continue
in_manifest "$rel" && continue
rm "$MIRROR/$rel"
note "deleted $MIRROR/$rel (not in the manifest — mirror means mirror)"
done < <(mirror_files)
find "$MIRROR" -mindepth 1 -type d -empty -delete
if [ ! -f "$MIRROR/$README_NAME" ] || ! readme_content | cmp -s - "$MIRROR/$README_NAME"; then
readme_content >"$MIRROR/$README_NAME"
note "wrote $MIRROR/$README_NAME"
fi
if [ ! -e AGENTS.md ]; then
stub_content >AGENTS.md
note "created the root AGENTS.md stub (scaffolded once, never overwritten)"
fi
if [ "$changed" -eq 0 ]; then
echo "docs-sync: nothing to do — $MIRROR/ already mirrors $origin exactly"
else
echo "docs-sync: $changed change(s); $MIRROR/ now mirrors $origin exactly"
fi
}
guard_plain_tree
case "$mode" in
check) run_check ;;
fix) run_fix ;;
esac