Skip to content

git 0.1.0 — branch-flow workflow: init, ship, promote, release #1

git 0.1.0 — branch-flow workflow: init, ship, promote, release

git 0.1.0 — branch-flow workflow: init, ship, promote, release #1

Workflow file for this run

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(" → ")})`);
});'