Files
pbakaus_impeccable/scripts/release.mjs
T
2c33196c51 Release: skill v4.1.0, CLI v3.6.0, extension v1.3.2 (#584)
* Release: skill v4.1.0, CLI v3.6.0, extension v1.3.2

Skill 4.1.0: the build path becomes a recorded setting with a per-round
toggle, the direction round routes challengers by verdict, surface rounds deal
structure, and critique delivers its report and its close.

CLI 3.6.0: contrast findings stop assuming white when the ground cannot be
read, waivers scope to the element that carries them, and Hermes Agent and
Antigravity install natively.

Extension 1.3.2: no source change, but the bundled engine is rebuilt at
release, so the same 59 rules ship with the false-positive work behind them.
Chrome and Firefox from the one manifest.

Harness output regenerated with build:release, which is what the version
validator checks against the manifests.

Written with AI assistance (Claude Code).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Bound release-note extraction to the entry it names

Every v4.0.x skill release shipped v4.0.0's notes. The extractor took the
first `<ul class="cf-items">` after the version header with no upper bound, and
the v4.0.1 through v4.0.4 entries wrote their bullets in a `cf-entry-list`
instead, so the search ran past all four and landed in v4.0.0. Nothing failed,
because finding a list somewhere was treated as success.

The search now stops at the entry's own `</article>` and fails with the reason
when the entry has no readable list, which is the case the old code silently
published its way through. The changelog side is fixed in impeccable-site,
where those five entries now use `cf-items` like the other 46: `cf-entry-list`
also had no CSS at all, so their bullets were rendering unstyled on the
changelog page.

Written with AI assistance (Claude Code).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 00:31:18 -04:00

322 lines
12 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.
//
// The search is bounded to this entry's own </article>. Unbounded, a version
// whose list carried any other class was silently skipped and the NEXT
// entry's bullets shipped as its release notes: every v4.0.x skill release
// went out carrying v4.0.0's notes that way, and nothing failed. A mismatch
// now stops the release instead of publishing another version's words.
const articleEnd = changelogHtml.indexOf('</article>', headerIdx);
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.');
if (articleEnd !== -1 && listStart > articleEnd) {
fail(`The changelog entry for "${cfg.changelogLabel}${version}" has no <ul class="cf-items"> of its own. `
+ 'Its bullets are in a list this script cannot read, and the next entry\'s notes would ship instead.');
}
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(/&times;/g, '×')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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(/&times;/g, '×');
md = md.replace(/&amp;/g, '&');
md = md.replace(/&lt;/g, '<');
md = md.replace(/&gt;/g, '>');
md = md.replace(/&quot;/g, '"');
md = md.replace(/&#39;/g, "'");
md = md.replace(/^[ \t]+/gm, '');
md = md.replace(/[ \t]+\n/g, '\n');
md = md.replace(/\n{3,}/g, '\n\n');
return md.trim();
}