-
Notifications
You must be signed in to change notification settings - Fork 0
199 lines (182 loc) · 9.79 KB
/
Copy pathci.yml
File metadata and controls
199 lines (182 loc) · 9.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
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(" → ")})`);
});'