Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions .claude/skills/bootstrap-repo/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions .claude/skills/bootstrap-repo/references/flavors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
51 changes: 51 additions & 0 deletions .claude/skills/bootstrap-repo/scripts/_sync_one_repo.py
Original file line number Diff line number Diff line change
@@ -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 <ghcommon_path> <target_repo_path>
"""

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]} <ghcommon_path> <target_repo_path>", 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))
81 changes: 81 additions & 0 deletions .claude/skills/bootstrap-repo/scripts/_update_registry.py
Original file line number Diff line number Diff line change
@@ -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))
45 changes: 36 additions & 9 deletions .claude/skills/bootstrap-repo/scripts/bootstrap_repo.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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] \
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
;;
Expand Down Expand Up @@ -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 <<EOF
Expand Down
7 changes: 6 additions & 1 deletion .claude/skills/bootstrap-repo/scripts/verify_bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,12 @@ else
DRIFT=1
fi
}
check '.required_status_checks.strict' 'true'
# required_status_checks may be null (no PR-triggering workflows). Only check
# .strict when the object exists.
HAS_RSC=$(echo "${PROTECTION}" | jq -r '.required_status_checks != null')
if [[ ${HAS_RSC} == "true" ]]; then
check '.required_status_checks.strict' 'true'
fi
check '.enforce_admins.enabled' 'false'
check '.required_pull_request_reviews.dismiss_stale_reviews' 'true'
check '.required_pull_request_reviews.required_approving_review_count' '0'
Expand Down
Loading