1#!/usr/bin/env node
2// Tags and publishes a GitHub release for one of three independently versioned
3// components: skill, cli, extension.
4//
5// Usage: node scripts/release.mjs <skill|cli|extension> [--dry-run]
6//
7// Refuses on a dirty tree, an unpushed HEAD, or a missing changelog entry.
8// For the skill component, also reruns `bun run build` and refuses if the
9// regenerated harness directories drift from what is committed.
10
11import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs';
12import { execSync } from 'node:child_process';
13import path from 'node:path';
14import { fileURLToPath } from 'node:url';
15
16const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
18const COMPONENTS = {
19 skill: {
20 manifest: '.claude-plugin/plugin.json',
21 sibling: '.claude-plugin/marketplace.json',
22 siblingVersion: (m) => m.plugins?.[0]?.version,
23 tagPrefix: 'skill-v',
24 label: 'Skill',
25 changelogLabel: 'v',
26 buildCmd: 'bun run build',
27 artifacts: ['dist/universal.zip'],
28 postReleaseHint: null,
29 tweetHeader: (v) => `Impeccable v${v} is out.`,
30 tweetCta: 'Install / update: npx skills add pbakaus/impeccable',
31 },
32 cli: {
33 manifest: 'package.json',
34 tagPrefix: 'cli-v',
35 label: 'CLI',
36 changelogLabel: 'CLI v',
37 buildCmd: null,
38 artifacts: [],
39 postReleaseHint: 'Run `npm publish` next to push the package to the npm registry.',
40 tweetHeader: (v) => `Impeccable CLI v${v} is out.`,
41 tweetCta: 'npm i -g impeccable',
42 },
43 extension: {
44 manifest: 'extension/manifest.json',
45 tagPrefix: 'ext-v',
46 label: 'Extension',
47 changelogLabel: 'Extension v',
48 buildCmd: 'bun run build:extension',
49 artifacts: ['dist/extension.zip'],
50 postReleaseHint: 'Upload `dist/extension.zip` to the Chrome Web Store dashboard to publish.',
51 tweetHeader: (v) => `Impeccable Chrome extension v${v} is out.`,
52 tweetCta: null,
53 },
54};
55
56const REPO_URL = 'https://github.com/pbakaus/impeccable';
57const TWEET_LIMIT = 280;
58
59const args = process.argv.slice(2);
60const dryRun = args.includes('--dry-run');
61const component = args.find((a) => !a.startsWith('--'));
62
63if (!component || !COMPONENTS[component]) {
64 console.error('usage: release.mjs <skill|cli|extension> [--dry-run]');
65 process.exit(1);
66}
67const cfg = COMPONENTS[component];
68
69function fail(msg) {
70 console.error(`✗ ${msg}`);
71 process.exit(1);
72}
73function ok(msg) {
74 console.log(`✓ ${msg}`);
75}
76function step(msg) {
77 console.log(`\n→ ${msg}`);
78}
79function run(cmd) {
80 return execSync(cmd, { cwd: repoRoot, encoding: 'utf8' }).trim();
81}
82function runMutating(cmd) {
83 if (dryRun) {
84 console.log(` [dry-run] ${cmd}`);
85 return;
86 }
87 execSync(cmd, { cwd: repoRoot, stdio: 'inherit' });
88}
89
90step(`Reading version from ${cfg.manifest}`);
91const manifest = JSON.parse(readFileSync(path.join(repoRoot, cfg.manifest), 'utf8'));
92const version = manifest.version;
93if (!version) fail(`No version field in ${cfg.manifest}`);
94ok(`${cfg.label} ${version}`);
95
96if (cfg.sibling) {
97 const sibling = JSON.parse(readFileSync(path.join(repoRoot, cfg.sibling), 'utf8'));
98 const siblingVersion = cfg.siblingVersion(sibling);
99 if (siblingVersion !== version) {
100 fail(`${cfg.manifest} (${version}) and ${cfg.sibling} (${siblingVersion}) disagree. Bump both.`);
101 }
102 ok(`${cfg.sibling} agrees`);
103}
104
105const tag = `${cfg.tagPrefix}${version}`;
106
107step('Checking working tree is clean');
108const status = run('git status --porcelain');
109if (status) fail(`Working tree is dirty. Commit or stash first:\n${status}`);
110ok('clean');
111
112if (cfg.buildCmd) {
113 step(`Rebuilding outputs (${cfg.buildCmd})`);
114 if (dryRun) {
115 console.log(` [dry-run] ${cfg.buildCmd}`);
116 } else {
117 execSync(cfg.buildCmd, { cwd: repoRoot, stdio: 'inherit' });
118 const postBuild = run('git status --porcelain');
119 if (postBuild) {
120 fail(`Build produced uncommitted changes. Run \`${cfg.buildCmd}\`, commit the result, then re-run.\n${postBuild}`);
121 }
122 ok('build outputs match source');
123 }
124}
125
126step('Checking HEAD is pushed to origin');
127const branch = run('git rev-parse --abbrev-ref HEAD');
128const head = run('git rev-parse HEAD');
129let remoteHead;
130try {
131 remoteHead = run(`git rev-parse origin/${branch}`);
132} catch {
133 fail(`No tracking branch origin/${branch}. Push first.`);
134}
135if (head !== remoteHead) fail(`HEAD is ahead of origin/${branch}. Push your commits first.`);
136ok(`origin/${branch} matches HEAD`);
137
138step(`Verifying tag ${tag} does not already exist`);
139let localTagExists = false;
140try {
141 run(`git rev-parse -q --verify "refs/tags/${tag}"`);
142 localTagExists = true;
143} catch {}
144if (localTagExists) fail(`Tag ${tag} already exists locally.`);
145const remoteTags = run('git ls-remote --tags origin');
146if (remoteTags.split('\n').some((line) => line.endsWith(`refs/tags/${tag}`))) {
147 fail(`Tag ${tag} already exists on origin.`);
148}
149ok('tag is free');
150
151step(`Extracting changelog entry for "${cfg.changelogLabel}${version}"`);
152const changelogSource = path.join(repoRoot, 'site/pages/index.astro');
153const indexHtml = readFileSync(changelogSource, 'utf8');
154const expectedHeader = `<span class="changelog-version">${cfg.changelogLabel}${version}</span>`;
155const headerIdx = indexHtml.indexOf(expectedHeader);
156if (headerIdx === -1) {
157 fail(`No changelog entry found for "${cfg.changelogLabel}${version}" in site/pages/index.astro. Add one before releasing.`);
158}
159const entryStart = indexHtml.lastIndexOf('<div class="changelog-entry"', headerIdx);
160const ulEnd = indexHtml.indexOf('</ul>', headerIdx);
161if (entryStart === -1 || ulEnd === -1) fail('Changelog entry markup is malformed.');
162const entryEnd = indexHtml.indexOf('</div>', ulEnd) + '</div>'.length;
163const entryHtml = indexHtml.slice(entryStart, entryEnd);
164
165const notes = htmlToMarkdown(entryHtml);
166ok('extracted');
167
168step('Verifying release artifacts exist');
169for (const artifact of cfg.artifacts) {
170 const abs = path.join(repoRoot, artifact);
171 if (!existsSync(abs)) fail(`Missing artifact: ${artifact}`);
172 ok(artifact);
173}
174
175console.log('\n--- Release notes preview ---');
176console.log(notes);
177console.log('--- end preview ---\n');
178
179step(`Creating annotated tag ${tag}`);
180const tagMessageFile = path.join(repoRoot, '.release-tag-msg.tmp');
181const releaseNotesFile = path.join(repoRoot, '.release-notes.tmp.md');
182if (!dryRun) {
183 writeFileSync(tagMessageFile, `${cfg.label} ${version}\n\n${notes}\n`);
184 writeFileSync(releaseNotesFile, notes);
185}
186try {
187 runMutating(`git tag -a ${tag} -F "${tagMessageFile}"`);
188 runMutating(`git push origin ${tag}`);
189
190 step(`Creating GitHub release ${tag}`);
191 const artifactArgs = cfg.artifacts.map((a) => `"${a}"`).join(' ');
192 const title = `${cfg.label} ${version}`;
193 runMutating(
194 `gh release create ${tag} --title "${title}" --notes-file "${releaseNotesFile}"${artifactArgs ? ' ' + artifactArgs : ''}`
195 );
196
197} finally {
198 if (!dryRun) {
199 try { unlinkSync(tagMessageFile); } catch {}
200 try { unlinkSync(releaseNotesFile); } catch {}
201 }
202}
203
204console.log(`\n✓ ${cfg.label} ${version} released as ${tag}`);
205if (cfg.postReleaseHint) {
206 console.log(`\n→ Next step: ${cfg.postReleaseHint}`);
207}
208
209const tweet = renderTweet(cfg, version, entryHtml, tag);
210console.log(`\n--- Tweet (${tweet.length}/${TWEET_LIMIT} chars) for @impeccable_ai ---`);
211console.log(tweet);
212console.log('--- end tweet ---');
213
214// Pull the bold lead text from each changelog bullet. Each <li> reads
215// "<strong>Headline.</strong> Body...", so the strong text alone is a
216// tweet-grade summary. Returns a list ordered by appearance.
217function extractHighlights(entryHtml) {
218 const highlights = [];
219 const liRe = /<li>([\s\S]*?)<\/li>/g;
220 let match;
221 while ((match = liRe.exec(entryHtml))) {
222 const strong = match[1].match(/<strong>([\s\S]*?)<\/strong>/);
223 if (!strong) continue;
224 const text = strong[1]
225 .replace(/<[^>]+>/g, '')
226 .replace(/×/g, '×')
227 .replace(/&/g, '&')
228 .replace(/</g, '<')
229 .replace(/>/g, '>')
230 .replace(/"/g, '"')
231 .replace(/'/g, "'")
232 .replace(/\s+/g, ' ')
233 .replace(/[.!?]+\s*$/, '')
234 .trim();
235 if (text) highlights.push(text);
236 }
237 return highlights;
238}
239
240function renderTweet(cfg, version, entryHtml, tag) {
241 const releaseUrl = `${REPO_URL}/releases/tag/${tag}`;
242 const header = cfg.tweetHeader(version);
243 const highlights = extractHighlights(entryHtml);
244 const tail = [cfg.tweetCta, releaseUrl].filter(Boolean).join('\n');
245
246 // Greedy: include as many highlights as fit. Always include the URL.
247 let bullets = '';
248 const bulletPrefix = '• ';
249 for (const h of highlights) {
250 const candidate = bullets + bulletPrefix + h + '\n';
251 const draft = [header, '', candidate.trimEnd(), '', tail].join('\n');
252 if (draft.length > TWEET_LIMIT) break;
253 bullets = candidate;
254 }
255
256 // Fallback if even the first highlight overflows: drop bullets entirely.
257 if (!bullets) {
258 return [header, '', tail].join('\n');
259 }
260 return [header, '', bullets.trimEnd(), '', tail].join('\n');
261}
262
263function htmlToMarkdown(html) {
264 let md = html;
265 md = md.replace(/<div class="changelog-version-header"[\s\S]*?<\/div>/, '');
266 md = md.replace(/<li>([\s\S]*?)<\/li>/g, (_, inner) => `- ${inner.trim()}\n`);
267 md = md.replace(/<strong>([\s\S]*?)<\/strong>/g, '**$1**');
268 md = md.replace(/<code>([\s\S]*?)<\/code>/g, '`$1`');
269 md = md.replace(/<a\s+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g, '[$2]($1)');
270 md = md.replace(/<\/?(ul|div|span)[^>]*>/g, '');
271 md = md.replace(/×/g, '×');
272 md = md.replace(/&/g, '&');
273 md = md.replace(/</g, '<');
274 md = md.replace(/>/g, '>');
275 md = md.replace(/"/g, '"');
276 md = md.replace(/'/g, "'");
277 md = md.replace(/^[ \t]+/gm, '');
278 md = md.replace(/[ \t]+\n/g, '\n');
279 md = md.replace(/\n{3,}/g, '\n\n');
280 return md.trim();
281}