diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5490034..cf2aa97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,3 +96,43 @@ jobs: with: changelog: CHANGELOG.monotonic.scratch.md base-ref: monotonic-fixture-base + + # Exercises actions/docs-sync the way a consumer does (issue #19's + # acceptance criterion). Its own job, unlike the exercises above: the + # composite reads the CONSUMER's tree at the workspace root, and a + # `uses:` step cannot change directory — so the fixture consumer must BE + # the workspace root, with ceremony itself checked out to a subdirectory + # (that path also serves as the action reference and the --source + # override; no ref carrying docs/VENDORED.txt exists to fetch until this + # lands, and the exercised bytes should be THIS PR's anyway). + docs-sync-exercise: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + path: ceremony-src + - name: Construct a fixture consumer at the workspace root + # The pin ref is scratch — --source overrides the fetch, but the + # pin line itself is still parsed and required (one pin governs + # machinery and doctrine; a consumer without one has nothing for + # the mirror to be verified against). + run: | + mkdir -p .github/workflows + printf '%s\n' \ + 'name: release' \ + 'on:' \ + ' push:' \ + ' branches: [main]' \ + 'jobs:' \ + ' release:' \ + ' uses: heavy-duty/ceremony/.github/workflows/release.yml@0.0.0-fixture' \ + > .github/workflows/release.yml + - name: Bootstrap the mirror (--fix) + uses: ./ceremony-src/actions/docs-sync + with: + mode: fix + source: ceremony-src + - name: Verify the mirror (--check, the mode consumers run) + uses: ./ceremony-src/actions/docs-sync + with: + source: ceremony-src diff --git a/actions/docs-sync/action.yml b/actions/docs-sync/action.yml new file mode 100644 index 0000000..3c3e803 --- /dev/null +++ b/actions/docs-sync/action.yml @@ -0,0 +1,33 @@ +name: Docs sync +description: >- + Verify (or write) the consumer's `.ceremony/` doctrine mirror — + byte-identical to heavy-duty/ceremony at the consumer's pinned ref + (issue #19). Machinery is consumed by reference; documents have no + runtime, so they are vendored, machine-written and machine-verified. + The pin is read from the consumer's `.github/workflows/release.yml` — + the same single line that pins the release workflow, so one pin governs + machinery and doctrine alike. The caller must have checked out its own + repository first: the tool reads the consumer's tree at the workspace. +inputs: + mode: + description: >- + "check" diffs the mirror against the pin and fails on any drift + (what CI runs); "fix" writes the mirror to match exactly. + required: false + default: check + source: + description: >- + Optional local ceremony checkout to mirror from instead of fetching + the pinned ref — offline tests, and previewing a ceremony PR against + a consumer before release. + required: false + default: "" +runs: + using: composite + steps: + - name: docs sync + shell: bash + env: + MODE: ${{ inputs.mode }} + SOURCE: ${{ inputs.source }} + run: bash "$GITHUB_ACTION_PATH/docs-sync.sh" diff --git a/actions/docs-sync/docs-sync.sh b/actions/docs-sync/docs-sync.sh new file mode 100644 index 0000000..ba42647 --- /dev/null +++ b/actions/docs-sync/docs-sync.sh @@ -0,0 +1,383 @@ +#!/usr/bin/env bash +set -euo pipefail + +# docs-sync.sh [--check|--fix] [--source ] — 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@` 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 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 ])" ;; + 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@' 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@'," \ + " 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@' 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. + fetch_tmp="$(mktemp -d)" + url="https://github.com/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?" + 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@` 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 diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index f430e4b..420a3b0 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -113,3 +113,48 @@ rows remain consumer-owned because paths and surfaces differ by repository. After adding the caller and configuration, run `workflow_dispatch` once to bootstrap labels on a fresh repository. Scheduled and PR-triggered runs only reconcile; they do not repeatedly upsert the taxonomy. + +## Doctrine mirror + +Machinery is consumed by reference — GitHub fetches the workflows and +actions above from the pin at run time — but documents have no runtime: an +agent reads the working tree it stands in. So the agent-facing doc set +(ceremony's `docs/VENDORED.txt`: AGENTS.md, TRIAGE.md, BUILDER.md, +REVIEWER.md, LABELS.md) is vendored into each consumer at **`.ceremony/`**, +byte-identical to ceremony at the pin, plus a generated `.ceremony/README.md` +marking the directory machine-managed. `actions/docs-sync` owns the copy: +`--fix` writes it (and deletes what the manifest dropped — mirror means +mirror), `--check` re-diffs it in CI on every PR, so a hand edit or a stale +pin goes red instead of quietly governing. + +The consumer's ci.yml gains the guard alongside the others: + +```yaml + - uses: actions/checkout@v4 + - uses: heavy-duty/ceremony/actions/docs-sync@ +``` + +`mode` defaults to `check`. There is no ref input: the action reads the pin +from the consumer's own `.github/workflows/release.yml` — the same single +`uses: …/release.yml@` line that pins the machinery, so one pin governs +machinery and doctrine alike, and a second pin cannot fall out of sync. + +**Bootstrap on adoption**: add the release and labels callers first (the pin +must exist — the mirror is verified against it), then run `--fix` once from +the repo root and commit `.ceremony/` together with the callers: + +```sh +curl -fsSL "https://raw.githubusercontent.com/heavy-duty/ceremony//actions/docs-sync/docs-sync.sh" \ + | bash -s -- --fix +``` + +If the repo has no root `AGENTS.md`, `--fix` also scaffolds the thin stub +that routes agents to `.ceremony/AGENTS.md` — created once, never +overwritten; it is per-repo content the moment you edit it, so `--check` +asserts only that it exists. + +**The pin-bump procedure**: bumping the pin is one PR — the pin line change +plus the re-synced mirror (run `--fix` locally, or let the red `--check` on +the bump PR say what is stale). The guard makes a half-done bump — pin +without mirror, mirror without pin — unmergeable, which is how a process +change rolls out: deliberately, per repo, reviewed. diff --git a/docs/VENDORED.txt b/docs/VENDORED.txt new file mode 100644 index 0000000..10c20a3 --- /dev/null +++ b/docs/VENDORED.txt @@ -0,0 +1,5 @@ +AGENTS.md +TRIAGE.md +BUILDER.md +REVIEWER.md +LABELS.md diff --git a/test/docs-sync.test.sh b/test/docs-sync.test.sh new file mode 100644 index 0000000..6987304 --- /dev/null +++ b/test/docs-sync.test.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +# Contract tests for actions/docs-sync (issue #19). Constructed SOURCE trees +# (a fake ceremony: manifest + docs) and CONSUMER trees (a release.yml +# caller with the pin line), driven offline via --source — the fetch path +# needs the network and is exercised by consumers, not here. The fake +# source's doc set is deliberately NOT the real five: a script that +# hardcodes the vendored list instead of reading the manifest fails these +# rows. 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" + +SCRIPT="$ROOT/actions/docs-sync/docs-sync.sh" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# --- fixture builders -------------------------------------------------------- + +# The main fake ceremony tree: three manifest entries, one in a subdirectory +# (the manifest is paths, not filenames — the mirror must carry structure). +SRC="$TMP/src" +mkdir -p "$SRC/docs" "$SRC/guide" +printf 'AGENTS.md\nRULES.md\nguide/DEEP.md\n' >"$SRC/docs/VENDORED.txt" +printf '# router v1\n' >"$SRC/AGENTS.md" +printf '# rules v1\n' >"$SRC/RULES.md" +printf '# deep v1\n' >"$SRC/guide/DEEP.md" + +# The same tree after a manifest removal: RULES.md is no longer vendored +# (the file itself may even still exist at the source — the MANIFEST is +# what defines the set). +SRC_DROPPED="$TMP/src-dropped" +cp -r "$SRC" "$SRC_DROPPED" +printf 'AGENTS.md\nguide/DEEP.md\n' >"$SRC_DROPPED/docs/VENDORED.txt" + +# consumer [pin-ref...] — a consumer tree whose release.yml carries +# one pin line per ref given (none → a caller with no pin at all). +consumer() { + local dir="$TMP/$1" ref + shift + rm -rf "$dir" + mkdir -p "$dir/.github/workflows" + { + printf 'name: release\non:\n push:\n branches: [main]\njobs:\n release:\n' + for ref in "$@"; do + printf ' uses: heavy-duty/ceremony/.github/workflows/release.yml@%s\n' "$ref" + done + } >"$dir/.github/workflows/release.yml" +} + +# in_consumer — run the script from inside a consumer tree. +in_consumer() { + local dir="$1" + shift + (cd "$TMP/$dir" && bash "$SCRIPT" "$@") +} + +# --- the pin: never guessed -------------------------------------------------- + +consumer no-pin +check "pin line absent → refuse, naming the workflow file" 1 \ + ".github/workflows/release.yml" in_consumer no-pin --check --source "$SRC" +check "pin refusal says it never guesses" 1 "never guesses a ref" \ + in_consumer no-pin --check --source "$SRC" + +consumer two-pins 0.3.0 0.4.0 +check "two pin lines → refuse (ambiguous)" 1 "exactly one" \ + in_consumer two-pins --check --source "$SRC" + +# Ceremony's own release.yml carries the pin SHAPE inside a header comment; +# a consumer pasting documentation into a comment must not double its pin. +consumer commented-pin 0.3.0 +printf ' # docs say: uses: heavy-duty/ceremony/.github/workflows/release.yml@\n' \ + >>"$TMP/commented-pin/.github/workflows/release.yml" +check "a commented-out pin line does not count as a second pin" 0 "" \ + in_consumer commented-pin --fix --source "$SRC" +check "commented pin: the mirror checks clean" 0 "exact mirror" \ + in_consumer commented-pin --check --source "$SRC" + +rm -rf "$TMP/no-workflow" +mkdir -p "$TMP/no-workflow" +check "missing release.yml entirely → refuse, naming it" 1 \ + "no .github/workflows/release.yml" in_consumer no-workflow --check --source "$SRC" + +# --- the manifest: single source of the set ----------------------------------- + +consumer fresh 0.3.0 +mkdir -p "$TMP/empty-src" +check "source without a manifest → refuse" 1 "docs/VENDORED.txt" \ + in_consumer fresh --check --source "$TMP/empty-src" + +mkdir -p "$TMP/blank-src/docs" +printf '\n \n' >"$TMP/blank-src/docs/VENDORED.txt" +check "empty manifest → refuse (a ceremony bug, not an empty set)" 1 "empty" \ + in_consumer fresh --check --source "$TMP/blank-src" + +mkdir -p "$TMP/ghost-src/docs" +printf 'GHOST.md\n' >"$TMP/ghost-src/docs/VENDORED.txt" +check "manifest naming a file the source lacks → refuse" 1 "GHOST.md" \ + in_consumer fresh --check --source "$TMP/ghost-src" + +mkdir -p "$TMP/escape-src/docs" +printf '../evil.md\n' >"$TMP/escape-src/docs/VENDORED.txt" +check "manifest path escaping the mirror → refuse" 1 "refusing manifest path" \ + in_consumer fresh --fix --source "$TMP/escape-src" + +# --- fix: from empty to exact mirror ------------------------------------------- + +check "check before any fix → .ceremony/ missing entirely" 1 \ + "missing entirely" in_consumer fresh --check --source "$SRC" + +check "--fix from empty writes the manifest set" 0 "added .ceremony/RULES.md" \ + in_consumer fresh --fix --source "$SRC" +check "vendored file is byte-identical to its source" 0 "" \ + cmp "$SRC/RULES.md" "$TMP/fresh/.ceremony/RULES.md" +check "a subdirectory manifest path mirrors with its directory" 0 "" \ + cmp "$SRC/guide/DEEP.md" "$TMP/fresh/.ceremony/guide/DEEP.md" + +check "--fix generated the README" 0 "" test -f "$TMP/fresh/.ceremony/README.md" +check "README marks the dir machine-managed" 0 "achine-managed" \ + cat "$TMP/fresh/.ceremony/README.md" +check "README names where the pin lives" 0 ".github/workflows/release.yml" \ + cat "$TMP/fresh/.ceremony/README.md" + +check "--fix scaffolded the root AGENTS.md stub" 0 ".ceremony/AGENTS.md" \ + cat "$TMP/fresh/AGENTS.md" + +check "in-sync mirror → check passes" 0 "exact mirror" \ + in_consumer fresh --check --source "$SRC" +check "--fix is idempotent (second run changes nothing, exits 0)" 0 \ + "nothing to do" in_consumer fresh --fix --source "$SRC" + +# --- check: every kind of drift fails, naming the offender --------------------- + +printf 'edited in place\n' >>"$TMP/fresh/.ceremony/RULES.md" +check "one byte changed in a vendored file → check fails naming it" 1 \ + ".ceremony/RULES.md" in_consumer fresh --check --source "$SRC" +check "drift message teaches the fix" 1 "run docs-sync --fix" \ + in_consumer fresh --check --source "$SRC" +check "--fix repairs the drift" 0 "updated .ceremony/RULES.md" \ + in_consumer fresh --fix --source "$SRC" + +rm "$TMP/fresh/.ceremony/RULES.md" +check "vendored file missing → check fails naming it" 1 \ + ".ceremony/RULES.md is missing" in_consumer fresh --check --source "$SRC" +in_consumer fresh --fix --source "$SRC" >/dev/null + +printf 'stray\n' >"$TMP/fresh/.ceremony/STRAY.md" +check "extra file under .ceremony/ → check fails naming it" 1 \ + ".ceremony/STRAY.md" in_consumer fresh --check --source "$SRC" +check "--fix deletes the extra (mirror means mirror)" 0 \ + "deleted .ceremony/STRAY.md" in_consumer fresh --fix --source "$SRC" +check "the extra is gone from disk" 1 "" test -f "$TMP/fresh/.ceremony/STRAY.md" + +# A manifest removal at the source: the orphaned vendored copy goes too. +check "--fix after manifest removal deletes the orphan" 0 \ + "deleted .ceremony/RULES.md" in_consumer fresh --fix --source "$SRC_DROPPED" +check "post-removal mirror is exact (and counts 2 files)" 0 "(2 files)" \ + in_consumer fresh --check --source "$SRC_DROPPED" +in_consumer fresh --fix --source "$SRC" >/dev/null + +# --- the root AGENTS.md stub: created once, never owned ------------------------- + +printf '# my own router, heavily edited\n' >"$TMP/fresh/AGENTS.md" +check "edited root AGENTS.md → check passes (content is per-repo)" 0 \ + "exact mirror" in_consumer fresh --check --source "$SRC" +check "--fix never overwrites an existing root AGENTS.md" 0 "nothing to do" \ + in_consumer fresh --fix --source "$SRC" +check "the edit survived --fix" 0 "my own router" cat "$TMP/fresh/AGENTS.md" + +rm "$TMP/fresh/AGENTS.md" +check "root AGENTS.md missing → check fails, teaching --fix" 1 \ + "run docs-sync --fix" in_consumer fresh --check --source "$SRC" +in_consumer fresh --fix --source "$SRC" >/dev/null + +# --- the README is machine-verified, not just machine-written ------------------- +# The marker that says "a hand edit goes red" must itself go red when +# hand-edited (kimi-bot, PR #43's review round). + +printf 'hand edit\n' >>"$TMP/fresh/.ceremony/README.md" +check "hand-edited README → check fails naming it" 1 ".ceremony/README.md" \ + in_consumer fresh --check --source "$SRC" +check "--fix rewrites the drifted README" 0 "wrote .ceremony/README.md" \ + in_consumer fresh --fix --source "$SRC" + +rm "$TMP/fresh/.ceremony/README.md" +check "missing README → check fails naming it" 1 \ + ".ceremony/README.md is missing" in_consumer fresh --check --source "$SRC" +in_consumer fresh --fix --source "$SRC" >/dev/null +check "README repaired → check green again" 0 "exact mirror" \ + in_consumer fresh --check --source "$SRC" + +# --- the mirror is plain files: symlinks and friends refused --------------------- +# PR #43's review round (codex-bot + kimi-bot, independent repros): cp +# writes THROUGH a committed link, cmp reads through it, and a `find +# -type f` scan cannot even see it. Both modes refuse; every row with a +# victim asserts the victim untouched. + +consumer sneaky 0.3.0 +in_consumer sneaky --fix --source "$SRC" >/dev/null +printf 'victim v1\n' >"$TMP/sneaky/victim.md" + +rm "$TMP/sneaky/.ceremony/RULES.md" +ln -s ../victim.md "$TMP/sneaky/.ceremony/RULES.md" +check "vendored path as symlink → check refuses naming it" 1 \ + ".ceremony/RULES.md" in_consumer sneaky --check --source "$SRC" +check "vendored path as symlink → fix refuses (never writes through)" 1 \ + "non-regular" in_consumer sneaky --fix --source "$SRC" +check "the link's target is untouched" 0 "victim v1" cat "$TMP/sneaky/victim.md" +rm "$TMP/sneaky/.ceremony/RULES.md" +in_consumer sneaky --fix --source "$SRC" >/dev/null + +# A stray link is exactly what the -type f extra-file scan was blind to: +# unlisted doctrine, previously invisible. +ln -s ../victim.md "$TMP/sneaky/.ceremony/STRAYLINK.md" +check "stray symlink (invisible to -type f) → check refuses" 1 \ + "STRAYLINK.md" in_consumer sneaky --check --source "$SRC" +check "stray symlink → fix refuses too (no silent deletion of a link)" 1 \ + "STRAYLINK.md" in_consumer sneaky --fix --source "$SRC" +rm "$TMP/sneaky/.ceremony/STRAYLINK.md" + +mkfifo "$TMP/sneaky/.ceremony/PIPE" +check "a fifo in the mirror → refused, not read" 1 "non-regular" \ + in_consumer sneaky --check --source "$SRC" +rm "$TMP/sneaky/.ceremony/PIPE" + +rm -rf "$TMP/sneaky/.ceremony/guide" +mkdir -p "$TMP/sneaky/elsewhere" +ln -s ../elsewhere "$TMP/sneaky/.ceremony/guide" +check "vendored subdirectory as symlink → fix refuses" 1 \ + ".ceremony/guide" in_consumer sneaky --fix --source "$SRC" +check "nothing was written into the linked directory's target" 1 "" \ + test -e "$TMP/sneaky/elsewhere/DEEP.md" +rm "$TMP/sneaky/.ceremony/guide" +in_consumer sneaky --fix --source "$SRC" >/dev/null +check "sneaky consumer repaired → check green" 0 "exact mirror" \ + in_consumer sneaky --check --source "$SRC" + +consumer linked-mirror 0.3.0 +mkdir -p "$TMP/linked-mirror-target" +ln -s ../linked-mirror-target "$TMP/linked-mirror/.ceremony" +check ".ceremony/ itself a symlink → check refuses" 1 "symlink" \ + in_consumer linked-mirror --check --source "$SRC" +check ".ceremony/ itself a symlink → fix refuses" 1 "symlink" \ + in_consumer linked-mirror --fix --source "$SRC" +check "the link's target directory stayed empty" 0 "" \ + test -z "$(ls -A "$TMP/linked-mirror-target")" + +consumer linked-stub 0.3.0 +printf 'stub victim\n' >"$TMP/linked-stub/other.md" +ln -s other.md "$TMP/linked-stub/AGENTS.md" +check "root AGENTS.md as symlink → check refuses" 1 \ + "AGENTS.md is a symlink" in_consumer linked-stub --check --source "$SRC" +check "root AGENTS.md as symlink → fix refuses" 1 \ + "AGENTS.md is a symlink" in_consumer linked-stub --fix --source "$SRC" +check "the scaffold did not write through the link" 0 "stub victim" \ + cat "$TMP/linked-stub/other.md" + +# Dangling is the sharpest case: -e is false through a dangling link, so a +# naive `[ ! -e ] && scaffold` writes the stub through it. +rm "$TMP/linked-stub/AGENTS.md" +ln -s does-not-exist.md "$TMP/linked-stub/AGENTS.md" +check "dangling AGENTS.md symlink → fix refuses (would write through)" 1 \ + "symlink" in_consumer linked-stub --fix --source "$SRC" +check "nothing appeared at the dangling target" 1 "" \ + test -e "$TMP/linked-stub/does-not-exist.md" + +rm "$TMP/linked-stub/AGENTS.md" +mkdir "$TMP/linked-stub/AGENTS.md" +check "root AGENTS.md as a directory → refused, named" 1 \ + "not a regular file" in_consumer linked-stub --fix --source "$SRC" + +# --- the action's wiring: inputs arrive as env vars ----------------------------- + +consumer env-wired 0.3.0 +env_sync() { + (cd "$TMP/env-wired" && MODE=fix SOURCE="$SRC" bash "$SCRIPT") +} +check "env vars drive the script the way action.yml does" 0 \ + "added .ceremony/RULES.md" env_sync + +env_bad_mode() { + (cd "$TMP/env-wired" && MODE=frobnicate SOURCE="$SRC" bash "$SCRIPT") +} +check "unknown MODE env refused" 1 "unknown mode" env_bad_mode +check "unknown flag refused" 1 "unknown argument" \ + in_consumer env-wired --check --source "$SRC" --frobnicate +check "--source without a directory refused" 1 "no such directory" \ + in_consumer env-wired --check --source "$TMP/does-not-exist" + +summary