#!/usr/bin/env node // Tags and publishes a GitHub release for one of three independently versioned // components: skill, cli, extension. // // Usage: node scripts/release.mjs [--dry-run] // // Refuses on a dirty tree, an unpushed HEAD, or a missing changelog entry. // For the skill component, also reruns `bun run build:release` and refuses if the // regenerated harness directories drift from what is committed. import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs'; import { execSync } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const COMPONENTS = { skill: { manifest: '.claude-plugin/plugin.json', sibling: '.claude-plugin/marketplace.json', siblingVersion: (m) => m.plugins?.[0]?.version, tagPrefix: 'skill-v', label: 'Skill', changelogLabel: 'v', buildCmd: 'bun run build:release', artifacts: ['dist/universal.zip'], postReleaseHint: null, tweetHeader: (v) => `Impeccable v${v} is out.`, tweetCta: 'Install / update: npx impeccable install', }, cli: { manifest: 'package.json', tagPrefix: 'cli-v', label: 'CLI', changelogLabel: 'CLI v', buildCmd: null, artifacts: [], postReleaseHint: 'Run `npm publish` next to push the package to the npm registry.', tweetHeader: (v) => `Impeccable CLI v${v} is out.`, tweetCta: 'npm i -g impeccable', }, extension: { manifest: 'extension/manifest.json', tagPrefix: 'ext-v', label: 'Extension', changelogLabel: 'Extension v', buildCmd: 'bun run build:extension', artifacts: ['dist/extension.zip', 'dist/extension-firefox.zip'], postReleaseHint: 'Upload `dist/extension.zip` to the Chrome Web Store dashboard, and `dist/extension-firefox.zip` to addons.mozilla.org (AMO), to publish.', tweetHeader: (v) => `Impeccable browser extension v${v} is out.`, tweetCta: null, }, }; const REPO_URL = 'https://github.com/pbakaus/impeccable'; const TWEET_LIMIT = 280; const args = process.argv.slice(2); const dryRun = args.includes('--dry-run'); const component = args.find((a) => !a.startsWith('--')); if (!component || !COMPONENTS[component]) { console.error('usage: release.mjs [--dry-run]'); process.exit(1); } const cfg = COMPONENTS[component]; function fail(msg) { console.error(`✗ ${msg}`); process.exit(1); } function ok(msg) { console.log(`✓ ${msg}`); } function step(msg) { console.log(`\n→ ${msg}`); } function run(cmd) { return execSync(cmd, { cwd: repoRoot, encoding: 'utf8' }).trim(); } function runMutating(cmd) { if (dryRun) { console.log(` [dry-run] ${cmd}`); return; } execSync(cmd, { cwd: repoRoot, stdio: 'inherit' }); } step(`Reading version from ${cfg.manifest}`); const manifest = JSON.parse(readFileSync(path.join(repoRoot, cfg.manifest), 'utf8')); const version = manifest.version; if (!version) fail(`No version field in ${cfg.manifest}`); ok(`${cfg.label} ${version}`); if (cfg.sibling) { const sibling = JSON.parse(readFileSync(path.join(repoRoot, cfg.sibling), 'utf8')); const siblingVersion = cfg.siblingVersion(sibling); if (siblingVersion !== version) { fail(`${cfg.manifest} (${version}) and ${cfg.sibling} (${siblingVersion}) disagree. Bump both.`); } ok(`${cfg.sibling} agrees`); } const tag = `${cfg.tagPrefix}${version}`; step('Checking working tree is clean'); const status = run('git status --porcelain'); if (status) fail(`Working tree is dirty. Commit or stash first:\n${status}`); ok('clean'); if (cfg.buildCmd) { step(`Rebuilding outputs (${cfg.buildCmd})`); if (dryRun) { console.log(` [dry-run] ${cfg.buildCmd}`); } else { execSync(cfg.buildCmd, { cwd: repoRoot, stdio: 'inherit' }); const postBuild = run('git status --porcelain'); if (postBuild) { fail(`Build produced uncommitted changes. Run \`${cfg.buildCmd}\`, commit the result, then re-run.\n${postBuild}`); } ok('build outputs match source'); } } step('Checking HEAD is pushed to origin'); const branch = run('git rev-parse --abbrev-ref HEAD'); const head = run('git rev-parse HEAD'); let remoteHead; try { remoteHead = run(`git rev-parse origin/${branch}`); } catch { fail(`No tracking branch origin/${branch}. Push first.`); } if (head !== remoteHead) fail(`HEAD is ahead of origin/${branch}. Push your commits first.`); ok(`origin/${branch} matches HEAD`); step(`Verifying tag ${tag} does not already exist`); let localTagExists = false; try { run(`git rev-parse -q --verify "refs/tags/${tag}"`); localTagExists = true; } catch {} if (localTagExists) fail(`Tag ${tag} already exists locally.`); const remoteTags = run('git ls-remote --tags origin'); if (remoteTags.split('\n').some((line) => line.endsWith(`refs/tags/${tag}`))) { fail(`Tag ${tag} already exists on origin.`); } ok('tag is free'); step(`Extracting changelog entry for "${cfg.changelogLabel}${version}"`); // The site (and its changelog) lives in the private impeccable-site repo; // fall back to a sibling checkout when releasing from the public repo. const changelogCandidates = [ path.join(repoRoot, 'site/pages/changelog.astro'), path.join(repoRoot, '..', 'impeccable-site', 'site/pages/changelog.astro'), ]; const changelogSource = changelogCandidates.find(p => existsSync(p)) || changelogCandidates[0]; const changelogHtml = readFileSync(changelogSource, 'utf8'); const expectedHeader = `${cfg.changelogLabel}${version}`; const headerIdx = changelogHtml.indexOf(expectedHeader); if (headerIdx === -1) { fail(`No changelog entry found for "${cfg.changelogLabel}${version}" in site/pages/changelog.astro. Add one before releasing.`); } // Notes are the entry's bullet list. Scoping to