diff --git a/package.json b/package.json index a68e452b3..b4c2e3389 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impeccable", - "version": "2.1.7", + "version": "2.1.8", "author": "Paul Bakaus", "description": "Design skills, commands, and anti-pattern detection for AI coding agents", "keywords": [ @@ -53,6 +53,9 @@ "test:live-e2e": "node --test --test-timeout=600000 tests/live-e2e.test.mjs", "prepack": "cp README.md README.repo.md && cp README.npm.md README.md", "postpack": "cp README.repo.md README.md && rm README.repo.md", + "release:skill": "node scripts/release.mjs skill", + "release:cli": "node scripts/release.mjs cli", + "release:ext": "node scripts/release.mjs extension", "screenshot": "bun run scripts/screenshot-antipatterns.js", "og-image": "bun run scripts/generate-og-image.js" }, diff --git a/public/index.html b/public/index.html index 66724b55d..8d7844d4b 100644 --- a/public/index.html +++ b/public/index.html @@ -933,6 +933,17 @@
+
+
+ CLI v2.1.8 + April 28, 2026 +
+ +
+
v3.0.4 diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100755 index 000000000..4e7cdc8d0 --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,216 @@ +#!/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` 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', + artifacts: ['dist/universal.zip'], + postReleaseHint: null, + }, + 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.', + }, + extension: { + manifest: 'extension/manifest.json', + tagPrefix: 'ext-v', + label: 'Extension', + changelogLabel: 'Extension v', + buildCmd: 'bun run build:extension', + artifacts: ['dist/extension.zip'], + postReleaseHint: 'Upload `dist/extension.zip` to the Chrome Web Store dashboard to publish.', + }, +}; + +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}"`); +const indexHtml = readFileSync(path.join(repoRoot, 'public/index.html'), 'utf8'); +const expectedHeader = `${cfg.changelogLabel}${version}`; +const headerIdx = indexHtml.indexOf(expectedHeader); +if (headerIdx === -1) { + fail(`No changelog entry found for "${cfg.changelogLabel}${version}" in public/index.html. Add one before releasing.`); +} +const entryStart = indexHtml.lastIndexOf('
', headerIdx); +if (entryStart === -1 || ulEnd === -1) fail('Changelog entry markup is malformed.'); +const entryEnd = indexHtml.indexOf('
', ulEnd) + '
'.length; +const entryHtml = indexHtml.slice(entryStart, entryEnd); + +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}`); +if (cfg.postReleaseHint) { + console.log(`\n→ Next step: ${cfg.postReleaseHint}`); +} + +function htmlToMarkdown(html) { + let md = html; + md = md.replace(/
/, ''); + md = md.replace(/
  • ([\s\S]*?)<\/li>/g, (_, inner) => `- ${inner.trim()}\n`); + md = md.replace(/([\s\S]*?)<\/strong>/g, '**$1**'); + md = md.replace(/([\s\S]*?)<\/code>/g, '`$1`'); + md = md.replace(/]*>([\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]+\n/g, '\n'); + md = md.replace(/\n{3,}/g, '\n\n'); + return md.trim(); +}