diff --git a/CLAUDE.md b/CLAUDE.md index 9b441f7..b625517 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,33 @@ This applies to: - Commit message trailers referenced in PR descriptions - Any auto-generated changelogs or release notes +## Repo Topology & Vendored Code (Read Before Editing) + +This repo **distributes** RepoMan; it is not where the engine is developed. + +- Engine scripts are developed and tested in + [rossoctl/automation](https://github.com/rossoctl/automation) `scripts/`. + Engine changes are made there, then vendored here. +- Everything under `skills/repoman/scripts/` — the shell scripts and the + non-shell extras (e.g. `doc-drift-check.py`) — is **vendored output**, + provenance-stamped in `skills/repoman/scripts/_vendor.json`. **Never edit + anything here in place.** To update it, run + `scripts/vendor-engine.sh --source --dest skills/repoman/scripts` + from a clean automation checkout that has your fix committed (the script + rejects a dirty source unless `--allow-dirty` is passed). +- RepoMan-specific skills are canonical **here**: their `SKILL.md`s are + authored in this repo. + [rossoctl/agent-skills](https://github.com/rossoctl/agent-skills) hosts + skills designed to work standalone as well as with RepoMan — do not port + RepoMan skill docs there. +- The per-program `skills//scripts/` copies are known-stale relative + to automation's engine (resync tracked in + [#5](https://github.com/rossoctl/repoman/issues/5)). + Do not treat either side as authoritative or copy between them piecemeal. +- The full contributor workflow (placement, testing, dry runs, vendoring, PR + rules) is in the README's `## Development` section — read it before + changing anything here. + ## Scope Discipline Only commit files directly related to the PR's stated purpose. Do not bundle diff --git a/README.md b/README.md index e0940ca..ec4ddb5 100644 --- a/README.md +++ b/README.md @@ -63,12 +63,68 @@ adding the marketplace to browse the authoritative list. Individual programs may have additional requirements documented in their `SKILL.md`. -## How it is built +## Development + +RepoMan is developed across three repositories: + +| Repository | Role | +|------------|------| +| [rossoctl/automation](https://github.com/rossoctl/automation) | Engine source of truth: the flat scripts in `scripts/`, plus tests and specs. All engine changes are made and tested there first. | +| [rossoctl/agent-skills](https://github.com/rossoctl/agent-skills) | Skills designed to work standalone as well as with RepoMan. | +| [rossoctl/repoman](https://github.com/rossoctl/repoman) (this repo) | Distribution (`rossoctl-repoman` marketplace) and the canonical home for RepoMan-specific `SKILL.md`s. The engine is not authored here. | + +See the `[CHANGELOG.md](https://github.com/rossoctl/repoman/blob/main/CHANGELOG.md)` for release history. + +### Contributor workflow + +1. **Place the work.** A skill that is useful on its own → author it in + agent-skills. A RepoMan-specific program (keeps state under `~/.repoman`) → + deterministic scripts in automation, `SKILL.md` in this repo. New programs + start from automation's `docs/program-template.md`. A new program also + needs repoman-side wiring that is not engine code: a + `.claude-plugin/marketplace.json` entry, a row in the README + `## Programs` table, and the program → skill mapping in + `skills/repoman/SKILL.md`. +2. **Test in automation**, next to the existing tests in `tests/`. Bash 3.2 + only: no `mapfile`, no associative arrays, BSD fallbacks for GNU-only + flags. If the skill writes to `~/.repoman`, give its `SKILL.md` a + machine-readable `### Requirements` block so `repoman-check.sh` can gate + the run. +3. **Keep real state out of test runs.** Point `REPOMAN_CONFIG_FILE` / + `REPOMAN_REPOS_FILE` at scratch files; never hand-edit `~/.repoman` + (`repoman-setup.sh` is its only writer); drive first runs through + `repoman-run.sh owner/repo --dry-run` and drop `--dry-run` only + once the output looks right. +4. **Vendor the engine** into this repo (below) — a new engine script must be + added to both the vendor manifest and its test fixture in + `tests/test-vendor-engine.sh`, including the expected counts — then run + that test. To try the plugin without reinstalling from the marketplace: + `claude plugin marketplace add ` then + `claude plugin install repoman@rossoctl-repoman`. +5. **Before the PR**: `git config core.hooksPath scripts/hooks`, sign off + with `git commit -s` (DCO), use a conventional title, one purpose per PR. + +### Vendoring the engine + +`skills/repoman/scripts/` is **generated output** — a vendored copy of the +automation engine, stamped with its source commit in +[`_vendor.json`](skills/repoman/scripts/_vendor.json). Never edit those files +directly: an in-place fix bypasses automation's tests and is silently +overwritten on the next vendor. To pick up engine changes, run it from this +repo against a **clean automation checkout** — the stamp records the source +HEAD SHA, so vendoring uncommitted changes would produce a stamp that does not +match the copied bytes (the script rejects a dirty source unless +`--allow-dirty` is passed): + +```bash +scripts/vendor-engine.sh --source \ + --dest skills/repoman/scripts +``` -The engine scripts are vendored from [rossoctl/automation](https://github.com/rossoctl/automation) -(the development source of truth) into this distribution repo, with a provenance stamp -(`skills/repoman/scripts/_vendor.json`) recording the exact source commit. See the -`CHANGELOG.md` for release history. +The script copies an explicit manifest (never a glob, so it cannot touch the +standalone skills' own `scripts/`), pre-validates the whole manifest before +copying anything, and rewrites `_vendor.json` with the automation HEAD SHA it +vendored from. `tests/test-vendor-engine.sh` is its regression test. ## License diff --git a/scripts/vendor-engine.sh b/scripts/vendor-engine.sh index 4cfa244..f22b82f 100644 --- a/scripts/vendor-engine.sh +++ b/scripts/vendor-engine.sh @@ -8,17 +8,36 @@ set -uo pipefail # Part of the RepoMan v0.1.0 packaged release (epic rossoctl/automation#72). # # vendor-engine.sh --source --dest +# [--allow-dirty] -SRC="" DEST="" +SRC="" DEST="" ALLOW_DIRTY=false while [ $# -gt 0 ]; do case "$1" in --source) SRC="${2:-}"; shift 2 ;; --dest) DEST="${2:-}"; shift 2 ;; + --allow-dirty) ALLOW_DIRTY=true; shift ;; *) echo "ERROR: unknown arg: $1" >&2; exit 2 ;; esac done [ -n "$SRC" ] && [ -n "$DEST" ] || { echo "ERROR: --source and --dest are required" >&2; exit 2; } +# Fail closed: if $SRC cannot be inspected at all (not a git checkout — e.g. +# a tarball extract — or git missing), the dirty check below would read the +# resulting empty output as "clean", the run would only die at the stamp +# step, and the dest would be left half-vendored with a stale _vendor.json. +# Unconditional: the stamp needs a source HEAD even with --allow-dirty. +if ! git -C "$SRC" rev-parse --git-dir >/dev/null 2>&1; then + echo "ERROR: cannot inspect $SRC (not a git checkout?); the provenance stamp requires a source HEAD" >&2; exit 1 +fi + +# Provenance guard: the stamp records the source HEAD SHA, so vendoring a +# dirty tree would stamp a commit that does not contain the copied bytes +# (see repoman#20). Reject a dirty source unless --allow-dirty is passed +# (scratch experiments only; the stamp then knowingly lies about the bytes). +if [ "$ALLOW_DIRTY" = false ] && [ -n "$(git -C "$SRC" status --porcelain 2>/dev/null)" ]; then + echo "ERROR: source $SRC has uncommitted changes; commit or stash them so the provenance stamp matches the vendored bytes (or pass --allow-dirty)" >&2; exit 1 +fi + # The 26 engine shell scripts. Flat set: nearly all source program-lib.sh, # which sources core.sh/github-api.sh/fork.sh/org.sh/repoman-requirements.sh. MANIFEST="automation-health-dashboard core dep-bump-fixer dep-bump-scanner \ diff --git a/tests/test-vendor-engine.sh b/tests/test-vendor-engine.sh index 413c700..0e9b68d 100644 --- a/tests/test-vendor-engine.sh +++ b/tests/test-vendor-engine.sh @@ -53,6 +53,20 @@ bash "$VENDOR" --source "$SRC" --dest "$DEST" >/dev/null 2>&1 got2=$(jq -r '.source_sha' "$DEST/_vendor.json" 2>/dev/null) if [ "$got2" = "$want" ]; then echo "PASS: idempotent stamp sha"; else echo "FAIL: idempotency $got2 != $want"; fail=1; fi +# --- Dirty source: rejected before any copy, dest left uncreated --- +DIRTYDEST="$TMP/dirtydest" +echo x >> "$SRC/scripts/org.sh" # uncommitted modification +if bash "$VENDOR" --source "$SRC" --dest "$DIRTYDEST" >/dev/null 2>&1; then + echo "FAIL: dirty source was not rejected"; fail=1 +else echo "PASS: dirty source rejected"; fi +if [ ! -d "$DIRTYDEST" ]; then echo "PASS: dirty rejection left dest uncreated"; else echo "FAIL: dirty rejection still created dest"; fail=1; fi +# --- Dirty source with --allow-dirty: override works, stamp still stamped --- +if bash "$VENDOR" --source "$SRC" --dest "$DIRTYDEST" --allow-dirty >/dev/null 2>&1; then + echo "PASS: --allow-dirty overrides the guard" +else echo "FAIL: --allow-dirty did not override"; fail=1; fi +if [ -f "$DIRTYDEST/org.sh" ]; then echo "PASS: --allow-dirty run copied files"; else echo "FAIL: --allow-dirty run copied nothing"; fail=1; fi +git -C "$SRC" checkout -- scripts/org.sh # restore for the tests below + # --- Missing source fails loud AND leaves the dest empty (no half-vendored dir) --- BADDEST="$TMP/baddest" missing_source_fails() ( @@ -71,4 +85,23 @@ if ! missing_source_fails; then echo "PASS: missing source fails loud"; else ech copied=$(find "$BADDEST" -maxdepth 1 -name '*.sh' 2>/dev/null | wc -l | tr -d ' ') if [ "${copied:-0}" = "0" ]; then echo "PASS: no half-vendored dir (dest empty on failure)"; else echo "FAIL: $copied files copied before the abort (half-vendored)"; fail=1; fi +# --- Non-git source (full manifest, no .git): rejected before any copy --- +# The dirty guard's `git status --porcelain` is empty for a non-git dir, which +# used to read as "clean"; the run then died only at the stamp step, after +# overwriting the dest. Must fail closed — with and without --allow-dirty +# (the stamp needs a source HEAD either way). +NOGITDEST="$TMP/nogitdest" +NOGIT="$TMP/nogit"; mkdir -p "$NOGIT/scripts" +# shellcheck disable=SC2086 +for f in $MANIFEST; do echo x > "$NOGIT/scripts/$f.sh"; done +# shellcheck disable=SC2086 +for f in $EXTRA_FILES; do echo x > "$NOGIT/scripts/$f"; done +if bash "$VENDOR" --source "$NOGIT" --dest "$NOGITDEST" >/dev/null 2>&1; then + echo "FAIL: non-git source was not rejected"; fail=1 +else echo "PASS: non-git source rejected"; fi +if [ ! -d "$NOGITDEST" ]; then echo "PASS: non-git rejection left dest uncreated"; else echo "FAIL: non-git rejection still created dest"; fail=1; fi +if bash "$VENDOR" --source "$NOGIT" --dest "$NOGITDEST" --allow-dirty >/dev/null 2>&1; then + echo "FAIL: non-git source accepted with --allow-dirty"; fail=1 +else echo "PASS: non-git source rejected even with --allow-dirty"; fi + [ "$fail" -eq 0 ] && echo "PASS" || { echo "FAILURES"; exit 1; }