Merge pull request #43 from claude-bot-andresmgsl/build/19-docs-sync
feat: actions/docs-sync — vendored doctrine, machine-verified
This commit is contained in:
commit
dbf857a94a
6 changed files with 800 additions and 0 deletions
40
.github/workflows/ci.yml
vendored
40
.github/workflows/ci.yml
vendored
|
|
@ -111,3 +111,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
|
||||
|
|
|
|||
33
actions/docs-sync/action.yml
Normal file
33
actions/docs-sync/action.yml
Normal file
|
|
@ -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"
|
||||
383
actions/docs-sync/docs-sync.sh
Normal file
383
actions/docs-sync/docs-sync.sh
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
#!/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.
|
||||
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@<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
|
||||
|
|
@ -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@<pinned-tag>
|
||||
```
|
||||
|
||||
`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@<ref>` 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/<pinned-tag>/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.
|
||||
|
|
|
|||
5
docs/VENDORED.txt
Normal file
5
docs/VENDORED.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
AGENTS.md
|
||||
TRIAGE.md
|
||||
BUILDER.md
|
||||
REVIEWER.md
|
||||
LABELS.md
|
||||
294
test/docs-sync.test.sh
Normal file
294
test/docs-sync.test.sh
Normal file
|
|
@ -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 <name> [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 <name> <args...> — 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@<tag>\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
|
||||
Loading…
Reference in a new issue