git 0.1.0 — branch-flow workflow: init, ship, promote, release #1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: ci | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| # The token every job below is handed. Without this block the run inherits the | |
| # repository default — a settings-page value that changes without a commit, and | |
| # these workflows run on pull_request, so a fork's code is what meets it. Read is | |
| # all any step here needs; declaring it in the file makes the file the guarantee, | |
| # and it travels with every clone and fork. | |
| permissions: | |
| contents: read | |
| jobs: | |
| validate: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: "22" | |
| # `claude plugin validate <dir>` checks only ONE manifest — marketplace.json wins when | |
| # both are present — so plugin.json would never be schema-checked here. This asserts the | |
| # fields that matter plus the agreement `claude plugin tag` relies on, with no deps. | |
| # The self-entry source is compared as a path, not a string: "." and "./" both name the | |
| # marketplace root and both load, so a cosmetic trailing slash must not fail the build. | |
| - name: Manifests are valid and agree | |
| run: | | |
| node -e ' | |
| const fs = require("fs"); | |
| const read = (p) => JSON.parse(fs.readFileSync(p, "utf8")); | |
| const plugin = read(".claude-plugin/plugin.json"); | |
| const market = read(".claude-plugin/marketplace.json"); | |
| let bad = false; | |
| const fail = (m) => { console.error(`✗ ${m}`); bad = true; }; | |
| for (const k of ["name", "version", "description"]) { | |
| if (!plugin[k]) fail(`plugin.json is missing "${k}"`); | |
| } | |
| if (!/^\d+\.\d+\.\d+/.test(plugin.version ?? "")) { | |
| fail(`plugin.json version "${plugin.version}" is not SemVer`); | |
| } | |
| const sources = (market.plugins ?? []).map((p) => p.source); | |
| const isSelf = (s) => typeof s === "string" && s.trim().replace(/\/+$/, "") === "."; | |
| const entry = (market.plugins ?? []).find((p) => isSelf(p.source)); | |
| if (!entry) fail(`marketplace.json has no self-entry pointing at the repo root; sources found: ${JSON.stringify(sources)}`); | |
| else { | |
| if (entry.name !== plugin.name) fail(`name mismatch: plugin.json "${plugin.name}" vs marketplace entry "${entry.name}"`); | |
| if ("version" in entry) fail("marketplace entry pins a version — a second source of truth that will drift"); | |
| } | |
| const top = fs.readFileSync("CHANGELOG.md", "utf8").match(/^## \[([^\]]+)\]/m); | |
| if (!top) fail("CHANGELOG.md has no version heading"); | |
| else if (top[1] !== plugin.version) { | |
| fail(`CHANGELOG.md top entry is [${top[1]}] but plugin.json is ${plugin.version}`); | |
| } | |
| if (bad) process.exit(1); | |
| console.log(`✓ manifests valid and in agreement (${plugin.name} ${plugin.version})`); | |
| ' | |
| # Every command advertised by the README must actually ship, and vice versa. A README | |
| # documenting a command that does not exist is the failure the professionality bar names. | |
| - name: README matches the shipped command set | |
| run: | | |
| node -e ' | |
| const fs = require("fs"); | |
| const readme = fs.readFileSync("README.md", "utf8"); | |
| const documented = new Set([...readme.matchAll(/\/git:([a-z-]+)/g)].map((m) => m[1])); | |
| const shipped = new Set( | |
| fs.readdirSync("skills", { withFileTypes: true }) | |
| .filter((d) => d.isDirectory() && fs.existsSync(`skills/${d.name}/SKILL.md`)) | |
| .map((d) => d.name) | |
| ); | |
| let bad = false; | |
| for (const c of documented) if (!shipped.has(c)) { console.error(`✗ README documents /git:${c}, which does not ship`); bad = true; } | |
| for (const c of shipped) if (!documented.has(c)) { console.error(`✗ /git:${c} ships but is undocumented`); bad = true; } | |
| if (bad) process.exit(1); | |
| console.log(`✓ README and skills/ agree (${[...shipped].sort().join(", ")})`); | |
| ' | |
| # A bundled path must resolve via ${CLAUDE_PLUGIN_ROOT}. A bare-relative `scripts/x.mjs` | |
| # looks fine and silently resolves against the CONSUMING project once installed. | |
| - name: Bundled paths are plugin-root-relative | |
| run: | | |
| if grep -rnE '(^|[^/A-Z_}])scripts/[a-z-]+\.mjs' skills/ | grep -v 'CLAUDE_PLUGIN_ROOT'; then | |
| echo "✗ bare-relative script path above — use \${CLAUDE_PLUGIN_ROOT}/scripts/…" | |
| exit 1 | |
| fi | |
| if grep -rnE '/Users/|~/' skills/ scripts/ .claude-plugin/; then | |
| echo "✗ absolute or home path above — breaks on another machine" | |
| exit 1 | |
| fi | |
| echo "✓ no bare-relative or absolute bundled paths" | |
| - name: Scripts parse | |
| run: | | |
| node --check scripts/resolve-git-config.mjs | |
| node --check scripts/check-config-examples.mjs | |
| node --check scripts/ship-prep.mjs | |
| node --check scripts/ship-apply.mjs | |
| node --check scripts/promote-prep.mjs | |
| node --check scripts/promote-apply.mjs | |
| node --check scripts/lib/classify.mjs | |
| node --check scripts/lib/commit-taxonomy.mjs | |
| node --check scripts/lib/env-file.mjs | |
| - name: resolve-git-config self-test | |
| run: node scripts/resolve-git-config.mjs --self-test | |
| # The prep scripts are top-level imperative — importing one runs git — so their pure | |
| # logic lives in lib/ and is tested there instead. | |
| - name: classify self-test | |
| run: node scripts/lib/classify.mjs --self-test | |
| - name: commit-taxonomy self-test | |
| run: node scripts/lib/commit-taxonomy.mjs --self-test | |
| - name: env-file self-test | |
| run: node scripts/lib/env-file.mjs --self-test | |
| # The example configs are documentation for someone writing their own | |
| # .claude/git.json AND the fixtures proving each project shape still resolves. | |
| - name: Validate the example config corpus | |
| run: node scripts/check-config-examples.mjs | |
| # End-to-end: a synthesized repo the scripts have never seen. Catches the failures | |
| # a pure self-test cannot — a script that crashes the moment it meets a real repo. | |
| - name: Prep scripts survive a real repo | |
| run: | | |
| set -e | |
| work="$(mktemp -d)" | |
| git -C "$work" init -q -b main . | |
| git -C "$work" config user.email ci@example.com | |
| git -C "$work" config user.name CI | |
| echo "# demo" > "$work/README.md" | |
| git -C "$work" add README.md | |
| git -C "$work" commit -q -m "chore: init" | |
| git -C "$work" checkout -q -b dev | |
| echo "export const x = 1;" > "$work/app.ts" | |
| git -C "$work" add app.ts | |
| git -C "$work" commit -q -m "feat(search): add ranking" | |
| git -C "$work" branch staging main | |
| mkdir -p "$work/.claude" | |
| echo '{"configVersion":1,"tiers":["dev","staging","main"]}' > "$work/.claude/git.json" | |
| git -C "$work" checkout -q -b feature/demo dev | |
| echo "dirty" >> "$work/app.ts" | |
| scripts="$PWD/scripts" | |
| cd "$work" | |
| # No `origin` here, so both must report that as a clean blocker rather than crash. | |
| # A brief carrying a blocker exits 1 by contract — that is the reported blocker, | |
| # not a crash — so capture the status rather than letting `-e` abort the step. | |
| # Asserting the exact code keeps a genuine crash (any other status) failing. | |
| # Both scripts write their brief to stdout; capture it for the assertions below. | |
| blocked() { | |
| local out="$1"; shift | |
| local rc=0 | |
| "$@" > "$out" || rc=$? | |
| test "$rc" -eq 1 || { echo "✗ expected exit 1 (blocked), got $rc: $*"; return 1; } | |
| } | |
| blocked ship-stdout.json node "$scripts/ship-prep.mjs" --no-gates --out brief.json | |
| node -e ' | |
| const b = require("./brief.json"); | |
| const fail = (m) => { console.error(`✗ ${m}`); process.exit(1); }; | |
| if (b.branch !== "feature/demo") fail(`branch: got ${b.branch}`); | |
| if (b.guesses.branchType !== "feature") fail(`branchType: got ${b.guesses.branchType}`); | |
| if (b.guesses.label !== "enhancement") fail(`label: got ${b.guesses.label}`); | |
| if (!Array.isArray(b.blockers) || !b.blockers.some((x) => /origin/.test(x))) { | |
| fail("a repo with no origin must be blocked on exactly that"); | |
| } | |
| console.log("✓ ship-prep produced a coherent brief on a fresh repo"); | |
| ' | |
| blocked promote.json node "$scripts/promote-prep.mjs" --no-fetch | |
| node -e ' | |
| const p = require("./promote.json"); | |
| const fail = (m) => { console.error(`✗ ${m}`); process.exit(1); }; | |
| if (!Array.isArray(p.blockers) || !p.blockers.some((x) => /origin\//.test(x))) { | |
| fail("a repo with no origin must block the promotion chain on exactly that"); | |
| } | |
| console.log("✓ promote-prep reported the broken chain on a fresh repo"); | |
| ' | |
| # The resolver must produce a usable chain with no .claude/git.json present — | |
| # a fresh install has to work on inference alone. | |
| - name: resolve-git-config works with no config file | |
| run: | | |
| test ! -f .claude/git.json | |
| node scripts/resolve-git-config.mjs --json | node -e ' | |
| let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => { | |
| const r = JSON.parse(s); | |
| if (!Array.isArray(r.tiers) || !r.tiers.length) { console.error("✗ no tiers inferred"); process.exit(1); } | |
| if (!r.roles || !r.roles.integration || !r.roles.production) { | |
| console.error("✗ roles missing integration or production"); process.exit(1); | |
| } | |
| console.log(`✓ inference-only resolve OK (chain: ${r.tiers.join(" → ")})`); | |
| });' |