release.mjs

  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(/&times;/g, '×')
227      .replace(/&amp;/g, '&')
228      .replace(/&lt;/g, '<')
229      .replace(/&gt;/g, '>')
230      .replace(/&quot;/g, '"')
231      .replace(/&#39;/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(/&times;/g, '×');
272  md = md.replace(/&amp;/g, '&');
273  md = md.replace(/&lt;/g, '<');
274  md = md.replace(/&gt;/g, '>');
275  md = md.replace(/&quot;/g, '"');
276  md = md.replace(/&#39;/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}