mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
The 4.0.0 release stranded npx-update users on a stale bundle for a day because the site deploy is a separate step nobody was reminded of. Skill releases now check /api/version and print the redeploy command when the served version lags. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
311 lines
11 KiB
JavaScript
Executable File
311 lines
11 KiB
JavaScript
Executable File
#!/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 <skill|cli|extension> [--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 <skill|cli|extension> [--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 = `<span class="cf-version">${cfg.changelogLabel}${version}</span>`;
|
||
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 <ul class="cf-items">
|
||
// skips the optional lead paragraph, before/after figure, and stat row
|
||
// that the headline release (v3.5.0) carries, so release notes stay clean.
|
||
const listStart = changelogHtml.indexOf('<ul class="cf-items">', headerIdx);
|
||
const listEnd = changelogHtml.indexOf('</ul>', listStart);
|
||
if (listStart === -1 || listEnd === -1) fail('Changelog entry markup is malformed.');
|
||
const entryHtml = changelogHtml.slice(listStart, listEnd + '</ul>'.length);
|
||
|
||
const notes = htmlToMarkdown(entryHtml);
|
||
ok('extracted');
|
||
|
||
step('Verifying release artifacts exist');
|
||
for (const artifact of cfg.artifacts) {
|
||
const abs = path.join(repoRoot, artifact);
|
||
if (!existsSync(abs)) fail(`Missing artifact: ${artifact}`);
|
||
ok(artifact);
|
||
}
|
||
|
||
console.log('\n--- Release notes preview ---');
|
||
console.log(notes);
|
||
console.log('--- end preview ---\n');
|
||
|
||
step(`Creating annotated tag ${tag}`);
|
||
const tagMessageFile = path.join(repoRoot, '.release-tag-msg.tmp');
|
||
const releaseNotesFile = path.join(repoRoot, '.release-notes.tmp.md');
|
||
if (!dryRun) {
|
||
writeFileSync(tagMessageFile, `${cfg.label} ${version}\n\n${notes}\n`);
|
||
writeFileSync(releaseNotesFile, notes);
|
||
}
|
||
try {
|
||
runMutating(`git tag -a ${tag} -F "${tagMessageFile}"`);
|
||
runMutating(`git push origin ${tag}`);
|
||
|
||
step(`Creating GitHub release ${tag}`);
|
||
const artifactArgs = cfg.artifacts.map((a) => `"${a}"`).join(' ');
|
||
const title = `${cfg.label} ${version}`;
|
||
runMutating(
|
||
`gh release create ${tag} --title "${title}" --notes-file "${releaseNotesFile}"${artifactArgs ? ' ' + artifactArgs : ''}`
|
||
);
|
||
|
||
} finally {
|
||
if (!dryRun) {
|
||
try { unlinkSync(tagMessageFile); } catch {}
|
||
try { unlinkSync(releaseNotesFile); } catch {}
|
||
}
|
||
}
|
||
|
||
console.log(`\n✓ ${cfg.label} ${version} released as ${tag}`);
|
||
|
||
// npx impeccable update serves from impeccable.style, not from this release:
|
||
// the site must be redeployed (its deploy overlays public main first). Warn
|
||
// loudly when the served version lags so a release never silently strands
|
||
// update users on old content again (the 4.0.0 release did exactly that).
|
||
if (component === 'skill' && !dryRun) {
|
||
try {
|
||
const res = await fetch('https://impeccable.style/api/version');
|
||
const served = (await res.json()).skills;
|
||
if (served === version) {
|
||
console.log(`✓ impeccable.style serves ${served}`);
|
||
} else {
|
||
console.log(`\n⚠ impeccable.style still serves ${served}, not ${version}.`);
|
||
console.log(' npx impeccable update users get the OLD version until the site redeploys:');
|
||
console.log(' cd ../impeccable-site && bun run deploy');
|
||
}
|
||
} catch {
|
||
console.log('⚠ could not reach impeccable.style/api/version to verify the served bundle');
|
||
}
|
||
}
|
||
if (cfg.postReleaseHint) {
|
||
console.log(`\n→ Next step: ${cfg.postReleaseHint}`);
|
||
}
|
||
|
||
const tweet = renderTweet(cfg, version, entryHtml, tag);
|
||
console.log(`\n--- Tweet (${tweet.length}/${TWEET_LIMIT} chars) for @impeccable_ai ---`);
|
||
console.log(tweet);
|
||
console.log('--- end tweet ---');
|
||
|
||
// Pull the bold lead text from each changelog bullet. Each <li> reads
|
||
// "<strong>Headline.</strong> Body...", so the strong text alone is a
|
||
// tweet-grade summary. Returns a list ordered by appearance.
|
||
function extractHighlights(entryHtml) {
|
||
const highlights = [];
|
||
const liRe = /<li>([\s\S]*?)<\/li>/g;
|
||
let match;
|
||
while ((match = liRe.exec(entryHtml))) {
|
||
const strong = match[1].match(/<strong>([\s\S]*?)<\/strong>/);
|
||
if (!strong) continue;
|
||
const text = strong[1]
|
||
.replace(/<[^>]+>/g, '')
|
||
.replace(/×/g, '×')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, "'")
|
||
.replace(/\s+/g, ' ')
|
||
.replace(/[.!?]+\s*$/, '')
|
||
.trim();
|
||
if (text) highlights.push(text);
|
||
}
|
||
return highlights;
|
||
}
|
||
|
||
function renderTweet(cfg, version, entryHtml, tag) {
|
||
const releaseUrl = `${REPO_URL}/releases/tag/${tag}`;
|
||
const header = cfg.tweetHeader(version);
|
||
const highlights = extractHighlights(entryHtml);
|
||
const tail = [cfg.tweetCta, releaseUrl].filter(Boolean).join('\n');
|
||
|
||
// Greedy: include as many highlights as fit. Always include the URL.
|
||
let bullets = '';
|
||
const bulletPrefix = '• ';
|
||
for (const h of highlights) {
|
||
const candidate = bullets + bulletPrefix + h + '\n';
|
||
const draft = [header, '', candidate.trimEnd(), '', tail].join('\n');
|
||
if (draft.length > TWEET_LIMIT) break;
|
||
bullets = candidate;
|
||
}
|
||
|
||
// Fallback if even the first highlight overflows: drop bullets entirely.
|
||
if (!bullets) {
|
||
return [header, '', tail].join('\n');
|
||
}
|
||
return [header, '', bullets.trimEnd(), '', tail].join('\n');
|
||
}
|
||
|
||
function htmlToMarkdown(html) {
|
||
let md = html;
|
||
md = md.replace(/<div class="changelog-version-header"[\s\S]*?<\/div>/, '');
|
||
md = md.replace(/<li>([\s\S]*?)<\/li>/g, (_, inner) => `- ${inner.trim()}\n`);
|
||
md = md.replace(/<strong>([\s\S]*?)<\/strong>/g, '**$1**');
|
||
md = md.replace(/<code>([\s\S]*?)<\/code>/g, '`$1`');
|
||
md = md.replace(/<a\s+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g, '[$2]($1)');
|
||
md = md.replace(/<\/?(ul|div|span)[^>]*>/g, '');
|
||
md = md.replace(/×/g, '×');
|
||
md = md.replace(/&/g, '&');
|
||
md = md.replace(/</g, '<');
|
||
md = md.replace(/>/g, '>');
|
||
md = md.replace(/"/g, '"');
|
||
md = md.replace(/'/g, "'");
|
||
md = md.replace(/^[ \t]+/gm, '');
|
||
md = md.replace(/[ \t]+\n/g, '\n');
|
||
md = md.replace(/\n{3,}/g, '\n\n');
|
||
return md.trim();
|
||
}
|