|
| 1 | +name: ci |
| 2 | + |
| 3 | +on: |
| 4 | + push: |
| 5 | + branches: [main] |
| 6 | + pull_request: |
| 7 | + |
| 8 | +# The token every job below is handed. Without this block the run inherits the |
| 9 | +# repository default — a settings-page value that changes without a commit, and |
| 10 | +# these workflows run on pull_request, so a fork's code is what meets it. Read is |
| 11 | +# all any step here needs; declaring it in the file makes the file the guarantee, |
| 12 | +# and it travels with every clone and fork. |
| 13 | +permissions: |
| 14 | + contents: read |
| 15 | + |
| 16 | +jobs: |
| 17 | + validate: |
| 18 | + runs-on: ubuntu-latest |
| 19 | + steps: |
| 20 | + - uses: actions/checkout@v7 |
| 21 | + |
| 22 | + - uses: actions/setup-node@v7 |
| 23 | + with: |
| 24 | + node-version: "22" |
| 25 | + |
| 26 | + # `claude plugin validate <dir>` checks only ONE manifest — marketplace.json wins when |
| 27 | + # both are present — so plugin.json would never be schema-checked here. This asserts the |
| 28 | + # fields that matter plus the agreement `claude plugin tag` relies on, with no deps. |
| 29 | + # The self-entry source is compared as a path, not a string: "." and "./" both name the |
| 30 | + # marketplace root and both load, so a cosmetic trailing slash must not fail the build. |
| 31 | + - name: Manifests are valid and agree |
| 32 | + run: | |
| 33 | + node -e ' |
| 34 | + const fs = require("fs"); |
| 35 | + const read = (p) => JSON.parse(fs.readFileSync(p, "utf8")); |
| 36 | + const plugin = read(".claude-plugin/plugin.json"); |
| 37 | + const market = read(".claude-plugin/marketplace.json"); |
| 38 | + let bad = false; |
| 39 | + const fail = (m) => { console.error(`✗ ${m}`); bad = true; }; |
| 40 | + for (const k of ["name", "version", "description"]) { |
| 41 | + if (!plugin[k]) fail(`plugin.json is missing "${k}"`); |
| 42 | + } |
| 43 | + if (!/^\d+\.\d+\.\d+/.test(plugin.version ?? "")) { |
| 44 | + fail(`plugin.json version "${plugin.version}" is not SemVer`); |
| 45 | + } |
| 46 | + const sources = (market.plugins ?? []).map((p) => p.source); |
| 47 | + const isSelf = (s) => typeof s === "string" && s.trim().replace(/\/+$/, "") === "."; |
| 48 | + const entry = (market.plugins ?? []).find((p) => isSelf(p.source)); |
| 49 | + if (!entry) fail(`marketplace.json has no self-entry pointing at the repo root; sources found: ${JSON.stringify(sources)}`); |
| 50 | + else { |
| 51 | + if (entry.name !== plugin.name) fail(`name mismatch: plugin.json "${plugin.name}" vs marketplace entry "${entry.name}"`); |
| 52 | + if ("version" in entry) fail("marketplace entry pins a version — a second source of truth that will drift"); |
| 53 | + } |
| 54 | + const top = fs.readFileSync("CHANGELOG.md", "utf8").match(/^## \[([^\]]+)\]/m); |
| 55 | + if (!top) fail("CHANGELOG.md has no version heading"); |
| 56 | + else if (top[1] !== plugin.version) { |
| 57 | + fail(`CHANGELOG.md top entry is [${top[1]}] but plugin.json is ${plugin.version}`); |
| 58 | + } |
| 59 | + if (bad) process.exit(1); |
| 60 | + console.log(`✓ manifests valid and in agreement (${plugin.name} ${plugin.version})`); |
| 61 | + ' |
| 62 | +
|
| 63 | + # Every command advertised by the README must actually ship, and vice versa. A README |
| 64 | + # documenting a command that does not exist is the failure the professionality bar names. |
| 65 | + - name: README matches the shipped command set |
| 66 | + run: | |
| 67 | + node -e ' |
| 68 | + const fs = require("fs"); |
| 69 | + const readme = fs.readFileSync("README.md", "utf8"); |
| 70 | + const documented = new Set([...readme.matchAll(/\/git:([a-z-]+)/g)].map((m) => m[1])); |
| 71 | + const shipped = new Set( |
| 72 | + fs.readdirSync("skills", { withFileTypes: true }) |
| 73 | + .filter((d) => d.isDirectory() && fs.existsSync(`skills/${d.name}/SKILL.md`)) |
| 74 | + .map((d) => d.name) |
| 75 | + ); |
| 76 | + let bad = false; |
| 77 | + for (const c of documented) if (!shipped.has(c)) { console.error(`✗ README documents /git:${c}, which does not ship`); bad = true; } |
| 78 | + for (const c of shipped) if (!documented.has(c)) { console.error(`✗ /git:${c} ships but is undocumented`); bad = true; } |
| 79 | + if (bad) process.exit(1); |
| 80 | + console.log(`✓ README and skills/ agree (${[...shipped].sort().join(", ")})`); |
| 81 | + ' |
| 82 | +
|
| 83 | + # A bundled path must resolve via ${CLAUDE_PLUGIN_ROOT}. A bare-relative `scripts/x.mjs` |
| 84 | + # looks fine and silently resolves against the CONSUMING project once installed. |
| 85 | + - name: Bundled paths are plugin-root-relative |
| 86 | + run: | |
| 87 | + if grep -rnE '(^|[^/A-Z_}])scripts/[a-z-]+\.mjs' skills/ | grep -v 'CLAUDE_PLUGIN_ROOT'; then |
| 88 | + echo "✗ bare-relative script path above — use \${CLAUDE_PLUGIN_ROOT}/scripts/…" |
| 89 | + exit 1 |
| 90 | + fi |
| 91 | + if grep -rnE '/Users/|~/' skills/ scripts/ .claude-plugin/; then |
| 92 | + echo "✗ absolute or home path above — breaks on another machine" |
| 93 | + exit 1 |
| 94 | + fi |
| 95 | + echo "✓ no bare-relative or absolute bundled paths" |
| 96 | +
|
| 97 | + - name: Scripts parse |
| 98 | + run: | |
| 99 | + node --check scripts/resolve-git-config.mjs |
| 100 | + node --check scripts/check-config-examples.mjs |
| 101 | + node --check scripts/ship-prep.mjs |
| 102 | + node --check scripts/ship-apply.mjs |
| 103 | + node --check scripts/promote-prep.mjs |
| 104 | + node --check scripts/promote-apply.mjs |
| 105 | + node --check scripts/lib/classify.mjs |
| 106 | + node --check scripts/lib/commit-taxonomy.mjs |
| 107 | + node --check scripts/lib/env-file.mjs |
| 108 | +
|
| 109 | + - name: resolve-git-config self-test |
| 110 | + run: node scripts/resolve-git-config.mjs --self-test |
| 111 | + |
| 112 | + # The prep scripts are top-level imperative — importing one runs git — so their pure |
| 113 | + # logic lives in lib/ and is tested there instead. |
| 114 | + - name: classify self-test |
| 115 | + run: node scripts/lib/classify.mjs --self-test |
| 116 | + |
| 117 | + - name: commit-taxonomy self-test |
| 118 | + run: node scripts/lib/commit-taxonomy.mjs --self-test |
| 119 | + |
| 120 | + - name: env-file self-test |
| 121 | + run: node scripts/lib/env-file.mjs --self-test |
| 122 | + |
| 123 | + # The example configs are documentation for someone writing their own |
| 124 | + # .claude/git.json AND the fixtures proving each project shape still resolves. |
| 125 | + - name: Validate the example config corpus |
| 126 | + run: node scripts/check-config-examples.mjs |
| 127 | + |
| 128 | + # End-to-end: a synthesized repo the scripts have never seen. Catches the failures |
| 129 | + # a pure self-test cannot — a script that crashes the moment it meets a real repo. |
| 130 | + - name: Prep scripts survive a real repo |
| 131 | + run: | |
| 132 | + set -e |
| 133 | + work="$(mktemp -d)" |
| 134 | + git -C "$work" init -q -b main . |
| 135 | + git -C "$work" config user.email ci@example.com |
| 136 | + git -C "$work" config user.name CI |
| 137 | + echo "# demo" > "$work/README.md" |
| 138 | + git -C "$work" add README.md |
| 139 | + git -C "$work" commit -q -m "chore: init" |
| 140 | + git -C "$work" checkout -q -b dev |
| 141 | + echo "export const x = 1;" > "$work/app.ts" |
| 142 | + git -C "$work" add app.ts |
| 143 | + git -C "$work" commit -q -m "feat(search): add ranking" |
| 144 | + git -C "$work" branch staging main |
| 145 | + mkdir -p "$work/.claude" |
| 146 | + echo '{"configVersion":1,"tiers":["dev","staging","main"]}' > "$work/.claude/git.json" |
| 147 | + git -C "$work" checkout -q -b feature/demo dev |
| 148 | + echo "dirty" >> "$work/app.ts" |
| 149 | +
|
| 150 | + scripts="$PWD/scripts" |
| 151 | + cd "$work" |
| 152 | + # No `origin` here, so both must report that as a clean blocker rather than crash. |
| 153 | + # A brief carrying a blocker exits 1 by contract — that is the reported blocker, |
| 154 | + # not a crash — so capture the status rather than letting `-e` abort the step. |
| 155 | + # Asserting the exact code keeps a genuine crash (any other status) failing. |
| 156 | + # Both scripts write their brief to stdout; capture it for the assertions below. |
| 157 | + blocked() { |
| 158 | + local out="$1"; shift |
| 159 | + local rc=0 |
| 160 | + "$@" > "$out" || rc=$? |
| 161 | + test "$rc" -eq 1 || { echo "✗ expected exit 1 (blocked), got $rc: $*"; return 1; } |
| 162 | + } |
| 163 | +
|
| 164 | + blocked ship-stdout.json node "$scripts/ship-prep.mjs" --no-gates --out brief.json |
| 165 | + node -e ' |
| 166 | + const b = require("./brief.json"); |
| 167 | + const fail = (m) => { console.error(`✗ ${m}`); process.exit(1); }; |
| 168 | + if (b.branch !== "feature/demo") fail(`branch: got ${b.branch}`); |
| 169 | + if (b.guesses.branchType !== "feature") fail(`branchType: got ${b.guesses.branchType}`); |
| 170 | + if (b.guesses.label !== "enhancement") fail(`label: got ${b.guesses.label}`); |
| 171 | + if (!Array.isArray(b.blockers) || !b.blockers.some((x) => /origin/.test(x))) { |
| 172 | + fail("a repo with no origin must be blocked on exactly that"); |
| 173 | + } |
| 174 | + console.log("✓ ship-prep produced a coherent brief on a fresh repo"); |
| 175 | + ' |
| 176 | + blocked promote.json node "$scripts/promote-prep.mjs" --no-fetch |
| 177 | + node -e ' |
| 178 | + const p = require("./promote.json"); |
| 179 | + const fail = (m) => { console.error(`✗ ${m}`); process.exit(1); }; |
| 180 | + if (!Array.isArray(p.blockers) || !p.blockers.some((x) => /origin\//.test(x))) { |
| 181 | + fail("a repo with no origin must block the promotion chain on exactly that"); |
| 182 | + } |
| 183 | + console.log("✓ promote-prep reported the broken chain on a fresh repo"); |
| 184 | + ' |
| 185 | +
|
| 186 | + # The resolver must produce a usable chain with no .claude/git.json present — |
| 187 | + # a fresh install has to work on inference alone. |
| 188 | + - name: resolve-git-config works with no config file |
| 189 | + run: | |
| 190 | + test ! -f .claude/git.json |
| 191 | + node scripts/resolve-git-config.mjs --json | node -e ' |
| 192 | + let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => { |
| 193 | + const r = JSON.parse(s); |
| 194 | + if (!Array.isArray(r.tiers) || !r.tiers.length) { console.error("✗ no tiers inferred"); process.exit(1); } |
| 195 | + if (!r.roles || !r.roles.integration || !r.roles.production) { |
| 196 | + console.error("✗ roles missing integration or production"); process.exit(1); |
| 197 | + } |
| 198 | + console.log(`✓ inference-only resolve OK (chain: ${r.tiers.join(" → ")})`); |
| 199 | + });' |
0 commit comments