Files
pbakaus_impeccable/scripts/release.mjs
T
Paul BakausandClaude Fable 5.1 4369ad538d Open the detector: the rule crates join the workspace, the C-ABI goes away
The detector is open source. The rules it ships were already public in this
repo's git history and in every npm tarball of the JS engine, so a closed
binary bought nothing it could keep; the moat is the service (the catalog,
the labs, the review pipeline), not the check functions. Keeping them behind
a prebuilt archive cost a C-ABI, an exact toolchain pin, a build-time
download, a second release to order ahead of every engine release, and a
serde layer that had to serve two encodings.

Deleted
- crates/core/src/ffi.rs, crates/core/build.rs, crates/core/tests/boundary.rs
  and the shim modules under src/checks and src/browser.
- crates/foundation/src/boundary.rs and the postcard dependency.
- DETECTOR_VERSION, scripts/check-detector-release.mjs and its test, the
  check:detector-release script, the detector gate and
  IMPECCABLE_SKIP_DETECTOR_CHECK in scripts/release.mjs.
- scripts/lib/detector-bundle.mjs and tests/detector-bundle.test.mjs (the
  vendoring path for the closed browser bundle).
- scripts/build-browser-detector.js and the build:browser script (a stub
  since the JS engine left the tree).
- xtask's detector-archive subcommand and its public-repo lookup.

Came back
- crates/core is now the rule logic itself: every check_* / scan_*, the
  browser adapters, the visual-contrast decisions. It re-exports foundation
  as before, so no consumer changed. Its vectors dispatcher is the union of
  both id tables again, and tests/vectors.rs replays the frozen vectors
  straight through it.
- crates/wasm and crates/xtask join the workspace. cargo xtask bundle builds
  the in-page bundle from browser-bundle/ plus the wasm core, writes
  dist/, refreshes the tracked crates/live/assets/detect-antipatterns-
  browser.js, and writes extension/detector/. bun run build:extension runs
  it instead of downloading.
- crates/live/assets/detect-antipatterns-browser.js is tracked again; live
  mode embeds it and serves it as /detect.js.
- Serde is back to plain derives: no is_human_readable branch in
  js::json_number, derived Serialize for Rgba and BrowserFinding with their
  skip_serializing_if attributes.
- profile.release has lto = "fat" again; rust-toolchain.toml is plain
  stable plus the wasm32 target. The rust, rust-windows and oracle CI jobs
  lose continue-on-error and can be required.

Verified
- cargo build --workspace --all-targets: clean, no warnings.
- cargo test --workspace: 346 pass, 0 fail (the 8 boundary tests are gone
  with the boundary).
- cargo build -p impeccable-wasm --target wasm32-unknown-unknown --release: ok.
- cargo xtask bundle && cargo xtask bundle --check: reproducible; the
  regenerated bundle is committed (it differs from the archived one, which
  was built with a pinned rustc and lto = false).
- cargo build --release -p impeccable: no linker warnings, 12.5 MB (the
  same source at lto = false is 13.1 MB).
- oracle: 795 pass, 0 fail, 0 accepted deltas, 0 missing goldens.
- bun run build, bun run build:extension, web-ext lint (0 errors,
  8 warnings), bun run test: 363 + 80 + 1 + 1 + 133 + 180 + 4 pass, 0 fail.
- impeccable detect --no-config --json tests/fixtures/antipatterns: 128.7 ms
  median of 5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
2026-09-03 09:11:33 -07:00

445 lines
18 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 the independently versioned
// components: skill, cli, extension, engine.
//
// Usage: node scripts/release.mjs <skill|cli|extension|engine> [--dry-run]
//
// `engine` is different: it only tags `engine-v<ENGINE_VERSION>` and pushes the
// tag; .github/workflows/release-engine.yml builds the five binaries and
// publishes the GitHub Release. It has no changelog entry and no local
// artifacts.
//
// 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';
import { checkEngineRelease } from './check-engine-release.mjs';
import { readEngineVersion } from './fetch-engine.mjs';
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',
// The skill's launcher and `impeccable install` dead-end without the engine
// release for the pinned ENGINE_VERSION. Enforce release order (D4).
engineGated: true,
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',
// The npm shim resolves the engine binary through the @impeccable/cli-<os>-<arch>
// platform packages (pinned at ENGINE_VERSION) and the dist channel; publishing
// it before those exist strands `npx impeccable`. Enforce release order (D4).
engineGated: true,
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',
// The extension ships a vendored WASM detector and does not exec the engine
// binary, so it is exempt from the engine release-order guard.
engineGated: false,
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,
},
engine: {
// Version comes from the root ENGINE_VERSION file, not a JSON manifest;
// releaseEngine() below owns this component's whole flow.
manifest: 'ENGINE_VERSION',
tagPrefix: 'engine-v',
label: 'Engine',
},
};
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|engine> [--dry-run]');
process.exit(1);
}
const cfg = COMPONENTS[component];
if (component === 'engine') {
await releaseEngine();
process.exit(0);
}
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`);
}
// Release-order guard (triage decision D4). Engine-gated components refuse to
// tag/publish until the engine release for the pinned ENGINE_VERSION is fully
// live: the five engine-v<version> release binaries + .sha256 and the five @impeccable/cli-<os>-<arch>
// npm platform packages. Without this the launcher, the npm shim, and
// `impeccable install` all dead-end. Set IMPECCABLE_SKIP_ENGINE_CHECK=1 only
// when you know the assets exist and the registry probe is unreachable.
if (cfg.engineGated && process.env.IMPECCABLE_SKIP_ENGINE_CHECK !== '1') {
const engineVersion = readEngineVersion(repoRoot);
step(`Verifying engine v${engineVersion} release assets are published (D4 release-order guard)`);
const result = await checkEngineRelease({ version: engineVersion });
if (!result.ok) {
console.error('✗ Engine release is incomplete. Missing assets:');
for (const m of result.missing) console.error(` · ${m.what}\n ${m.url}`);
fail(
`Refusing to release ${cfg.label} ${version}: engine v${engineVersion} is not fully published.\n` +
` Publish engine v${engineVersion} (bun run release:engine) AND the five @impeccable/cli-<os>-<arch>\n` +
' npm platform packages first. Ordering: engine release → platform packages → skill/CLI release.\n' +
' See CLAUDE.md "Releases" and the engine repo docs/REVIEW-TRIAGE.md D4.'
);
}
ok(`engine v${engineVersion} release assets all present`);
} else if (cfg.engineGated) {
step('Skipping engine release-order guard (IMPECCABLE_SKIP_ENGINE_CHECK=1)');
}
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);
if (articleEnd === -1) fail('Changelog entry markup is malformed.');
// EVERY list in the entry, not the first. A long release is grouped into
// themed <ul>s behind cf-group labels, and taking only the first published one
// theme and silently dropped the rest.
const entryScope = changelogHtml.slice(headerIdx, articleEnd);
const lists = entryScope.match(/<ul class="cf-items">[\s\S]*?<\/ul>/g);
if (!lists || !lists.length) {
// An unclosed list and a missing one are different repairs, so they get
// different messages. Reporting "no list" for markup that plainly has one
// sends you looking for the wrong thing.
const opened = entryScope.includes('<ul class="cf-items">');
fail(opened
? `The changelog entry for "${cfg.changelogLabel}${version}" opens a <ul class="cf-items"> that is never closed inside its <article>. Fix the markup.`
: `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 no notes would ship.');
}
const entryHtml = lists.join('\n');
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();
}
// The engine release: verify, tag, push. CI does the building and publishing
// (release-engine.yml), so the maintainer's machine never needs five
// toolchains. The whole workspace builds from source, so there is nothing to
// fetch and nothing to order ahead of it.
async function releaseEngine() {
step('Reading version from ENGINE_VERSION');
const version = readEngineVersion(repoRoot);
if (!/^\d+\.\d+\.\d+/.test(version)) fail(`ENGINE_VERSION "${version}" is not a version`);
ok(`Engine ${version}`);
step('Checking package.json optionalDependencies pin the same engine version');
const pkg = JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
const pins = Object.entries(pkg.optionalDependencies || {}).filter(([name]) => name.startsWith('@impeccable/cli-'));
const wrong = pins.filter(([, range]) => String(range).replace(/^[^\d]*/, '') !== version);
if (wrong.length) fail(`package.json pins ${wrong.map(([n, r]) => `${n}@${r}`).join(', ')}; expected ${version}. Bump them with ENGINE_VERSION.`);
ok(`${pins.length} platform package pins agree`);
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');
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(`Creating annotated tag ${tag}`);
runMutating(`git tag -a ${tag} -m "Engine ${version}"`);
runMutating(`git push origin ${tag}`);
console.log(`\n✓ Engine ${version} tagged as ${tag}`);
console.log(`\n→ Next step: watch the release-engine workflow (${REPO_URL}/actions/workflows/release-engine.yml).`);
console.log(` It publishes the five binaries + .sha256 as ${REPO_URL}/releases/tag/${tag}.`);
console.log(' Then publish the five @impeccable/cli-<os>-<arch> npm platform packages, then release the CLI/skill.');
}