diff --git a/.claude/skills/bootstrap-repo/SKILL.md b/.claude/skills/bootstrap-repo/SKILL.md index 3e9e7918..6d1c6052 100644 --- a/.claude/skills/bootstrap-repo/SKILL.md +++ b/.claude/skills/bootstrap-repo/SKILL.md @@ -1,6 +1,6 @@ --- name: bootstrap-repo -description: Create a new GitHub repo or apply ghcommon house standards to an existing one. Use when the user says "create a new repo", "bootstrap a repo", "set up a new repo to our standards", "apply our standards to repo X", "make this repo compliant", "new action/library/service repo", or wants merge settings, branch protection, labels, dependabot, and instruction files configured in one shot. Wraps gh repo create, applies repo-level settings (rebase-only, auto-merge, no delete) and branch protection on main, runs ghcommon's label and instruction-file sync scripts, and seeds flavor-specific overlay files. +description: Create a new GitHub repo or apply ghcommon house standards to an existing one. Use when the user says "create a new repo", "bootstrap a repo", "set up a new repo to our standards", "apply our standards to repo X", "make this repo compliant", "new action/library/service/cli repo", or wants merge settings, branch protection, labels, dependabot, and instruction files configured in one shot. Wraps gh repo create, applies repo-level settings (rebase-only, auto-merge, no delete) and branch protection on main, runs ghcommon's label and instruction-file sync scripts, seeds flavor-specific overlay files, and records the bootstrap in ghcommon/.github/bootstrapped-repos.json. --- # bootstrap-repo @@ -42,7 +42,8 @@ Steps 2 and 8 are split because branch protection requires `main` to exist. - Building a GitHub Action → `--flavor action` - Building a reusable library/SDK/package → `--flavor library` -- Building a deployable service or app → `--flavor service` +- Building a deployable service or app (containerized) → `--flavor service` +- Building a distributable binary/CLI (goreleaser, brew, etc) → `--flavor cli` ## Usage @@ -75,6 +76,16 @@ Standalone use: --repo-path ~/repos/github.com/jdfalk/my-thing ``` +## Registry + +Every successful bootstrap appends/updates `ghcommon/.github/bootstrapped-repos.json`. +Schema: `{version, repos: [{owner, name, flavor, mode, first_bootstrapped, last_bootstrapped}]}`. +Idempotent (existing entries get `last_bootstrapped` refreshed; `first_bootstrapped` +is preserved). + +The skill writes the file but does not commit it. Commit it from ghcommon to share +across machines and to enable future "bootstrap-all" sweeps. + ## What this skill does NOT do - **Secrets** — does not seed `CI_APP_ID` or any other secret. Run diff --git a/.claude/skills/bootstrap-repo/references/flavors.md b/.claude/skills/bootstrap-repo/references/flavors.md index ec97832e..1fc8cc47 100644 --- a/.claude/skills/bootstrap-repo/references/flavors.md +++ b/.claude/skills/bootstrap-repo/references/flavors.md @@ -60,6 +60,30 @@ Deployable application or service. - `.github/workflows/publish-docker.yml` (recommended) - `.github/dependabot.yml`, `.github/copilot-instructions.md`, `.github/instructions/` +## cli + +Distributable binary or CLI tool, shipped via goreleaser / brew / winget / +direct install scripts — **not** a containerized service. + +**Overlay files seeded if missing** + +- `CHANGELOG.md` — Keep-a-Changelog template (same as library) + +**Template repo**: `jdfalk/jft-cli-template` (does not yet exist; falls back to empty) + +**Notable required files** + +- `README.md` with **Installation** section (per-platform install steps), `LICENSE` +- `CHANGELOG.md` +- `.goreleaser.yml` or equivalent release config (tool-specific; not seeded by skill) +- Language-appropriate manifest (`go.mod`, `Cargo.toml`, etc) +- `.github/dependabot.yml`, `.github/copilot-instructions.md`, `.github/instructions/` + +**Differs from `service`**: no `Dockerfile`. CLIs are distributed as native binaries +to user machines, not deployed as containers. Differs from `library`: produces a +binary artifact, not an importable package; CHANGELOG drives the release tag, not +a SemVer of an exported API. + ## Common to all flavors These come from `ghcommon/scripts/sync-repo-setup.py`, regardless of flavor: diff --git a/.claude/skills/bootstrap-repo/scripts/_sync_one_repo.py b/.claude/skills/bootstrap-repo/scripts/_sync_one_repo.py new file mode 100755 index 00000000..9fd4a9f3 --- /dev/null +++ b/.claude/skills/bootstrap-repo/scripts/_sync_one_repo.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Run ghcommon's RepoSetupSyncer.sync_repository() against a single target. + +ghcommon's `sync-repo-setup.py` only exposes `sync_all_repositories()` via its +CLI, which auto-discovers all repos. We need a single-target flow for bootstrap. +This shim imports the existing class and calls the existing per-repo method, +without modifying ghcommon's script. + +Usage: + _sync_one_repo.py +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + + +def load_syncer_module(ghcommon: Path): + script = ghcommon / "scripts" / "sync-repo-setup.py" + spec = importlib.util.spec_from_file_location("sync_repo_setup", script) + if spec is None or spec.loader is None: + raise RuntimeError(f"could not load {script}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def main(argv: list[str]) -> int: + if len(argv) != 3: + print(f"usage: {argv[0]} ", file=sys.stderr) + return 2 + ghcommon = Path(argv[1]).resolve() + target = Path(argv[2]).resolve() + if not (ghcommon / "scripts" / "sync-repo-setup.py").is_file(): + print(f"error: ghcommon sync-repo-setup.py not found under {ghcommon}", file=sys.stderr) + return 1 + if not target.is_dir(): + print(f"error: target not a directory: {target}", file=sys.stderr) + return 1 + + module = load_syncer_module(ghcommon) + syncer = module.RepoSetupSyncer(source_repo=ghcommon, dry_run=False) + result = syncer.sync_repository(target_repo=target) + print(f"sync result: {result.get('repo_name')}: {result.get('status', 'ok')}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.claude/skills/bootstrap-repo/scripts/_update_registry.py b/.claude/skills/bootstrap-repo/scripts/_update_registry.py new file mode 100755 index 00000000..72ebbcfa --- /dev/null +++ b/.claude/skills/bootstrap-repo/scripts/_update_registry.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Upsert an entry into the bootstrapped-repos registry. + +Lives at `${GHCOMMON}/.github/bootstrapped-repos.json`. Records every repo +that has been through `bootstrap_repo.sh` so we can iterate the set later +(drift sweeps, mass standards updates, etc). + +Schema: + {"version": 1, "repos": [{owner, name, flavor, mode, first_bootstrapped, last_bootstrapped}, ...]} + +Idempotent: an existing (owner, name) pair has its `flavor`, `mode`, and +`last_bootstrapped` updated; `first_bootstrapped` is preserved. + +Usage: + _update_registry.py --registry PATH --owner OWNER --name NAME \\ + --flavor FLAVOR --mode MODE +""" + +from __future__ import annotations + +import argparse +import datetime +import json +import sys +from pathlib import Path + +SCHEMA_VERSION = 1 + + +def load_registry(path: Path) -> dict: + if not path.exists(): + return {"version": SCHEMA_VERSION, "repos": []} + with path.open() as f: + data = json.load(f) + data.setdefault("version", SCHEMA_VERSION) + data.setdefault("repos", []) + return data + + +def upsert(data: dict, owner: str, name: str, flavor: str, mode: str) -> dict: + now = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + for entry in data["repos"]: + if entry.get("owner") == owner and entry.get("name") == name: + entry["flavor"] = flavor + entry["mode"] = mode + entry["last_bootstrapped"] = now + return data + data["repos"].append( + { + "owner": owner, + "name": name, + "flavor": flavor, + "mode": mode, + "first_bootstrapped": now, + "last_bootstrapped": now, + } + ) + data["repos"].sort(key=lambda e: (e.get("owner", ""), e.get("name", ""))) + return data + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--registry", type=Path, required=True) + parser.add_argument("--owner", required=True) + parser.add_argument("--name", required=True) + parser.add_argument("--flavor", required=True) + parser.add_argument("--mode", required=True) + args = parser.parse_args(argv[1:]) + + args.registry.parent.mkdir(parents=True, exist_ok=True) + data = load_registry(args.registry) + data = upsert(data, args.owner, args.name, args.flavor, args.mode) + with args.registry.open("w") as f: + json.dump(data, f, indent=2) + f.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.claude/skills/bootstrap-repo/scripts/bootstrap_repo.sh b/.claude/skills/bootstrap-repo/scripts/bootstrap_repo.sh index f65f6c19..fc192f47 100755 --- a/.claude/skills/bootstrap-repo/scripts/bootstrap_repo.sh +++ b/.claude/skills/bootstrap-repo/scripts/bootstrap_repo.sh @@ -5,10 +5,10 @@ # create — gh repo create + apply standards from empty # adopt — apply standards to an existing repo # -# Flavors: action | library | service +# Flavors: action | library | service | cli # # Usage: -# bootstrap_repo.sh --flavor {action|library|service} \ +# bootstrap_repo.sh --flavor {action|library|service|cli} \ # --mode {create|adopt} \ # --name REPO \ # [--owner jdfalk] [--private|--public] \ @@ -101,8 +101,8 @@ done exit 2 } -case "${FLAVOR}" in action | library | service) ;; *) - echo "error: --flavor must be action, library, or service" >&2 +case "${FLAVOR}" in action | library | service | cli) ;; *) + echo "error: --flavor must be action, library, service, or cli" >&2 exit 2 ;; esac @@ -170,8 +170,8 @@ fi echo "→ Syncing instruction/dependabot/AGENTS files from ghcommon" SYNC_SCRIPT="${GHCOMMON}/scripts/sync-repo-setup.py" if [[ -f ${SYNC_SCRIPT} ]]; then - python3 "${SYNC_SCRIPT}" --target "${REPO_PATH}" || - echo " (sync-repo-setup.py failed; continuing — may need flag adjustment)" + python3 "${SCRIPT_DIR}/_sync_one_repo.py" "${GHCOMMON}" "${REPO_PATH}" || + echo " (sync shim failed; continuing)" else echo " (sync-repo-setup.py not found at ${SYNC_SCRIPT}; skipping)" fi @@ -182,9 +182,10 @@ if [[ ${SKIP_LABELS} -eq 0 ]]; then echo "→ Syncing labels from ghcommon/labels.json" LABEL_SCRIPT="${GHCOMMON}/scripts/sync-github-labels.py" if [[ -f ${LABEL_SCRIPT} ]]; then - GH_TOKEN="${GH_TOKEN:-$(gh auth token)}" \ - python3 "${LABEL_SCRIPT}" --owner "${OWNER}" --repo "${NAME}" \ - --labels "${GHCOMMON}/labels.json" || + # ghcommon's sync-github-labels.py reads PAT_TOKEN or GITHUB_TOKEN, not GH_TOKEN + GITHUB_TOKEN="${PAT_TOKEN:-${GITHUB_TOKEN:-$(gh auth token)}}" \ + python3 "${LABEL_SCRIPT}" "${OWNER}" "${NAME}" \ + --labels-file "${GHCOMMON}/labels.json" || echo " (sync-github-labels.py failed; continuing)" fi fi @@ -228,6 +229,22 @@ service) # TODO: choose a base image FROM scratch # TODO: COPY artifacts and set ENTRYPOINT +EOF + fi + ;; +cli) + # CLI / distributable binary. No Dockerfile (binary is shipped via goreleaser + # / brew / winget, not containers). Same CHANGELOG seed as library. + if [[ ! -f "${REPO_PATH}/CHANGELOG.md" ]]; then + cat >"${REPO_PATH}/CHANGELOG.md" <<'EOF' +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] EOF fi ;; @@ -276,6 +293,16 @@ fi "${SCRIPT_DIR}/verify_bootstrap.sh" --owner "${OWNER}" --repo "${NAME}" \ --repo-path "${REPO_PATH}" --flavor "${FLAVOR}" +# ---------- update registry ---------- + +REGISTRY="${GHCOMMON}/.github/bootstrapped-repos.json" +python3 "${SCRIPT_DIR}/_update_registry.py" \ + --registry "${REGISTRY}" \ + --owner "${OWNER}" --name "${NAME}" \ + --flavor "${FLAVOR}" --mode "${MODE}" +echo "→ Registry updated: ${REGISTRY}" +echo " (commit ghcommon/.github/bootstrapped-repos.json to record this bootstrap)" + # ---------- next steps ---------- cat <