From 44a25fa2419cc34db6c3534989c94a59d69e36a8 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Fri, 4 Sep 2026 10:12:17 -0700 Subject: [PATCH] Release: publish the npm platform packages in one command bun run release:platform-packages downloads each engine-v binary with its .sha256 sidecar (required; nothing unverified is published), stages the package from cli/platform-packages/ with the version stamped, the executable at bin/ and the repo LICENSE, and runs npm publish --access public. Targets already on the registry are skipped so a re-run resumes after a partial failure. Preconditions: package.json pins equal ENGINE_VERSION and npm is logged in. Co-Authored-By: Claude Code Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY --- cli/platform-packages/README.md | 4 +- package.json | 1 + scripts/publish-platform-packages.mjs | 215 +++++++++++++++++++++++ scripts/release.mjs | 2 +- scripts/test-suites.mjs | 1 + tests/publish-platform-packages.test.mjs | 113 ++++++++++++ 6 files changed, 333 insertions(+), 3 deletions(-) create mode 100644 scripts/publish-platform-packages.mjs create mode 100644 tests/publish-platform-packages.test.mjs diff --git a/cli/platform-packages/README.md b/cli/platform-packages/README.md index be6f60425..f7d066b61 100644 --- a/cli/platform-packages/README.md +++ b/cli/platform-packages/README.md @@ -6,8 +6,8 @@ the one matching the host (`os` / `cpu` fields), and the shim resolves `/bin/impeccable[.exe]` from it before falling back to the user cache or a download. -They are **published by the engine release**, not from this repo: the release -pipeline copies each template, sets `version` to the engine version, drops the +They are **published from the engine release**, not built here: `bun run +release:platform-packages` (`scripts/publish-platform-packages.mjs`) copies each template, sets `version` to the engine version, drops the built binary at `bin/impeccable[.exe]` (executable), and publishes it under `@impeccable`. The version pinned in this repo's `package.json` `optionalDependencies` must equal `ENGINE_VERSION`. diff --git a/package.json b/package.json index 4aebb8cdf..77ecfd5ed 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "release:cli": "node scripts/release.mjs cli", "release:ext": "node scripts/release.mjs extension", "release:engine": "node scripts/release.mjs engine", + "release:platform-packages": "node scripts/publish-platform-packages.mjs", "check:engine-release": "node scripts/check-engine-release.mjs" }, "optionalDependencies": { diff --git a/scripts/publish-platform-packages.mjs b/scripts/publish-platform-packages.mjs new file mode 100644 index 000000000..e98239354 --- /dev/null +++ b/scripts/publish-platform-packages.mjs @@ -0,0 +1,215 @@ +#!/usr/bin/env node +/** + * Publish the five @impeccable/cli-- npm platform packages for the + * pinned ENGINE_VERSION from the engine-v GitHub release. + * + * Step 3 of the engine cutover, as one command: + * + * bun run release:platform-packages # publish every target not yet on npm + * bun run release:platform-packages -- --dry-run + * bun run release:platform-packages -- --target linux-x64 + * + * For each target it downloads the release binary and its .sha256 sidecar + * (the sidecar is required here: nothing unverified is ever published), + * stages a package from cli/platform-packages//package.json with the + * version stamped, the binary at bin/impeccable[.exe] (executable) and the + * repo LICENSE, then runs `npm publish --access public` from the staging dir. + * Targets already published at this version are skipped, so a re-run after a + * partial failure picks up where it stopped. + * + * Preconditions checked up front: package.json optionalDependencies pin the + * same version as ENGINE_VERSION, and `npm whoami` succeeds (log in first). + * + * Environment: + * IMPECCABLE_DOWNLOAD_BASE release channel root (default: this repo's GitHub Releases) + * NPM_REGISTRY registry used for the published-already probe (default: npmjs) + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { + ENGINE_TARGETS, + assetUrl, + binaryName, + readEngineVersion, +} from './fetch-engine.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const NPM_REGISTRY = (process.env.NPM_REGISTRY || 'https://registry.npmjs.org').replace(/\/$/, ''); + +export function packageName(target) { + return `@impeccable/cli-${target}`; +} + +/** + * Build the package.json for one platform package from its template. + * Pure: takes the template object and returns the stamped copy. + */ +export function stampTemplate(template, target, version) { + const bin = binaryName(target); + const expected = `bin/${bin}`; + const binEntries = Object.values(template.bin || {}); + if (binEntries.length !== 1 || binEntries[0] !== expected) { + throw new Error(`${packageName(target)} template must map its bin to ${expected} (got ${JSON.stringify(template.bin)})`); + } + if (template.name !== packageName(target)) { + throw new Error(`template name ${template.name} does not match ${packageName(target)}`); + } + return { ...template, version }; +} + +/** + * Stage one package into outDir: package.json, bin/ (0755), LICENSE. + * Returns the staged directory. No network; the caller supplies the bytes. + */ +export function stagePackage({ target, version, binary, template, license, outDir }) { + const dir = path.join(outDir, target); + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(path.join(dir, 'bin'), { recursive: true }); + const pkg = stampTemplate(template, target, version); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n'); + const binPath = path.join(dir, 'bin', binaryName(target)); + fs.writeFileSync(binPath, binary); + fs.chmodSync(binPath, 0o755); + fs.writeFileSync(path.join(dir, 'LICENSE'), license); + return dir; +} + +async function download(url) { + const res = await fetch(url, { redirect: 'follow' }); + if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`); + return Buffer.from(await res.arrayBuffer()); +} + +/** Download and checksum-verify one release binary. The sidecar is mandatory. */ +export async function fetchVerifiedBinary(target, version, base) { + const url = assetUrl(version, target, base); + let binary; + try { + binary = await download(url); + } catch (err) { + throw new Error(`release asset not available: ${err.message}. Publish engine-v${version} first (bun run release:engine) and wait for release-engine.yml to finish.`); + } + let sidecar; + try { + sidecar = (await download(`${url}.sha256`)).toString('utf-8').trim().split(/\s+/)[0]; + } catch (err) { + throw new Error(`cannot verify ${url}: its .sha256 sidecar is missing (${err.message}); refusing to publish an unverified binary`); + } + if (!/^[0-9a-f]{64}$/i.test(sidecar || '')) { + throw new Error(`cannot verify ${url}: its .sha256 sidecar is empty or malformed; refusing to publish an unverified binary`); + } + const actual = createHash('sha256').update(binary).digest('hex'); + if (actual !== sidecar.toLowerCase()) { + throw new Error(`checksum mismatch for ${url}: expected ${sidecar}, got ${actual}`); + } + return binary; +} + +/** True when @ already exists on the registry. */ +export async function isPublished(target, version, registry = NPM_REGISTRY) { + const name = packageName(target).replace('/', '%2F'); + const res = await fetch(`${registry}/${name}/${version}`, { redirect: 'follow' }); + if (res.status === 404) return false; + if (!res.ok) throw new Error(`registry probe failed for ${packageName(target)}@${version}: ${res.status} ${res.statusText}`); + return true; +} + +function checkPins(version) { + const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8')); + const pins = pkg.optionalDependencies || {}; + const bad = ENGINE_TARGETS.filter((t) => pins[packageName(t)] !== version); + if (bad.length) { + const listed = bad.map((t) => `${packageName(t)}@${pins[packageName(t)] || 'missing'}`).join(', '); + throw new Error(`package.json optionalDependencies do not pin ENGINE_VERSION ${version}: ${listed}. Bump them with ENGINE_VERSION first.`); + } +} + +function npmWhoami() { + const res = spawnSync('npm', ['whoami', '--registry', NPM_REGISTRY], { encoding: 'utf-8' }); + if (res.status !== 0) { + throw new Error(`npm is not logged in for ${NPM_REGISTRY} (npm whoami failed: ${(res.stderr || '').trim()}). Run \`npm login\` first.`); + } + return res.stdout.trim(); +} + +function npmPublish(dir, { dryRun }) { + const args = ['publish', '--access', 'public', '--registry', NPM_REGISTRY]; + if (dryRun) args.push('--dry-run'); + const res = spawnSync('npm', args, { cwd: dir, stdio: 'inherit' }); + if (res.status !== 0) throw new Error(`npm publish failed in ${dir} (exit ${res.status})`); +} + +function parseArgs(argv) { + const opts = { targets: [], dryRun: false, force: false, keep: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--dry-run') opts.dryRun = true; + else if (a === '--force') opts.force = true; + else if (a === '--keep') opts.keep = true; + else if (a === '--target') opts.targets.push(argv[++i]); + else if (a === '--help' || a === '-h') { + console.log('usage: publish-platform-packages.mjs [--dry-run] [--force] [--keep] [--target ]...'); + process.exit(0); + } else { + console.error(`unknown argument: ${a}`); + process.exit(2); + } + } + return opts; +} + +export async function main(argv = process.argv.slice(2)) { + const opts = parseArgs(argv); + const version = readEngineVersion(); + const targets = opts.targets.length ? opts.targets : ENGINE_TARGETS; + for (const t of targets) { + if (!ENGINE_TARGETS.includes(t)) throw new Error(`unsupported target ${t} (known: ${ENGINE_TARGETS.join(', ')})`); + } + checkPins(version); + const licensePath = path.join(ROOT, 'LICENSE'); + if (!fs.existsSync(licensePath)) throw new Error(`LICENSE is missing at ${licensePath}; the platform packages ship it`); + const license = fs.readFileSync(licensePath); + const user = opts.dryRun ? '(dry run)' : npmWhoami(); + console.log(`→ Publishing @impeccable/cli-* platform packages for engine ${version} as ${user}`); + + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-platform-packages-')); + const results = []; + try { + for (const target of targets) { + const name = packageName(target); + if (!opts.force && (await isPublished(target, version))) { + console.log(` ${name}@${version} already published, skipping`); + results.push({ target, status: 'skipped' }); + continue; + } + console.log(` ${name}: downloading ${assetUrl(version, target)}`); + const binary = await fetchVerifiedBinary(target, version, process.env.IMPECCABLE_DOWNLOAD_BASE); + const template = JSON.parse(fs.readFileSync(path.join(ROOT, 'cli', 'platform-packages', target, 'package.json'), 'utf-8')); + const dir = stagePackage({ target, version, binary, template, license, outDir }); + console.log(` ${name}: staged ${dir} (${binary.length} bytes, checksum verified)`); + npmPublish(dir, { dryRun: opts.dryRun }); + results.push({ target, status: opts.dryRun ? 'dry-run' : 'published' }); + } + } finally { + if (opts.keep) console.log(` staging kept at ${outDir}`); + else fs.rmSync(outDir, { recursive: true, force: true }); + } + + console.log(''); + for (const r of results) console.log(`✓ ${packageName(r.target)}@${version}: ${r.status}`); + if (!opts.dryRun) { + console.log('\n→ Next: `bun run check:engine-release` should now be fully green; then the clean-HOME launcher check, then merge.'); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((err) => { + console.error(`✗ ${err.message}`); + process.exit(1); + }); +} diff --git a/scripts/release.mjs b/scripts/release.mjs index 5520a77d2..7e84414a6 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -440,5 +440,5 @@ async function releaseEngine() { 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-- npm platform packages, then release the CLI/skill.'); + console.log(' Then publish the five @impeccable/cli-- npm platform packages with `bun run release:platform-packages`, then release the CLI/skill.'); } diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index acc483c78..7f88170be 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -64,6 +64,7 @@ export const SUITES = { files: [ 'tests/ci-test-plan.test.mjs', 'tests/cli-shim.test.mjs', + 'tests/publish-platform-packages.test.mjs', 'tests/github-sheriff.test.mjs', 'tests/hook-build.test.mjs', 'tests/openai-plugin.test.mjs', diff --git a/tests/publish-platform-packages.test.mjs b/tests/publish-platform-packages.test.mjs new file mode 100644 index 000000000..22e1848cc --- /dev/null +++ b/tests/publish-platform-packages.test.mjs @@ -0,0 +1,113 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import http from 'node:http'; +import { createHash } from 'node:crypto'; +import { + stampTemplate, + stagePackage, + fetchVerifiedBinary, + isPublished, + packageName, +} from '../scripts/publish-platform-packages.mjs'; +import { ENGINE_TARGETS, binaryName } from '../scripts/fetch-engine.mjs'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const VERSION = '9.9.9'; + +function template(target) { + return JSON.parse(fs.readFileSync(path.join(ROOT, 'cli', 'platform-packages', target, 'package.json'), 'utf-8')); +} + +describe('platform package templates', () => { + it('every target has a template whose bin points at bin/ and whose name matches', () => { + for (const target of ENGINE_TARGETS) { + const stamped = stampTemplate(template(target), target, VERSION); + assert.equal(stamped.name, packageName(target)); + assert.equal(stamped.version, VERSION); + assert.deepEqual(Object.values(stamped.bin), [`bin/${binaryName(target)}`]); + assert.ok(stamped.files.includes('bin/') && stamped.files.includes('LICENSE'), `${target} ships bin/ and LICENSE`); + } + }); + + it('refuses a template whose bin does not match the binary name', () => { + const bad = { ...template('darwin-arm64'), bin: { 'impeccable-darwin-arm64': 'bin/impeccable.exe' } }; + assert.throws(() => stampTemplate(bad, 'darwin-arm64', VERSION), /must map its bin to bin\/impeccable /); + }); + + it('stages package.json, an executable binary, and the LICENSE', () => { + const out = fs.mkdtempSync(path.join(os.tmpdir(), 'ipp-stage-')); + try { + for (const target of ['linux-x64', 'windows-x64']) { + const dir = stagePackage({ + target, + version: VERSION, + binary: Buffer.from(`binary for ${target}`), + template: template(target), + license: Buffer.from('LICENSE TEXT'), + outDir: out, + }); + const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')); + assert.equal(pkg.version, VERSION); + const bin = path.join(dir, 'bin', binaryName(target)); + assert.equal(fs.readFileSync(bin, 'utf-8'), `binary for ${target}`); + if (process.platform !== 'win32') assert.equal(fs.statSync(bin).mode & 0o111, 0o111, 'binary is executable'); + assert.equal(fs.readFileSync(path.join(dir, 'LICENSE'), 'utf-8'), 'LICENSE TEXT'); + } + } finally { + fs.rmSync(out, { recursive: true, force: true }); + } + }); +}); + +describe('release asset verification and registry probe', () => { + const binary = Buffer.from('release binary bytes'); + const goodSha = createHash('sha256').update(binary).digest('hex'); + let server; + let base; + const routes = new Map(); + + before(async () => { + server = http.createServer((req, res) => { + const handler = routes.get(req.url); + if (!handler) { + res.statusCode = 404; + res.end('not found'); + return; + } + handler(res); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + base = `http://127.0.0.1:${server.address().port}`; + }); + + after(() => server.close()); + + const asset = `/engine-v${VERSION}/impeccable-linux-x64`; + const serve = (body) => (res) => { res.statusCode = 200; res.end(body); }; + + it('publishes only a binary whose sidecar matches', async () => { + routes.set(asset, serve(binary)); + routes.set(`${asset}.sha256`, serve(`${goodSha} impeccable-linux-x64\n`)); + const got = await fetchVerifiedBinary('linux-x64', VERSION, base); + assert.deepEqual(got, binary); + }); + + it('refuses when the sidecar is missing, empty, or mismatched', async () => { + routes.set(asset, serve(binary)); + routes.delete(`${asset}.sha256`); + await assert.rejects(fetchVerifiedBinary('linux-x64', VERSION, base), /sidecar is missing.*refusing to publish/); + routes.set(`${asset}.sha256`, serve('')); + await assert.rejects(fetchVerifiedBinary('linux-x64', VERSION, base), /empty or malformed.*refusing to publish/); + routes.set(`${asset}.sha256`, serve('0'.repeat(64))); + await assert.rejects(fetchVerifiedBinary('linux-x64', VERSION, base), /checksum mismatch/); + }); + + it('reports a published version as published and a 404 as not', async () => { + routes.set(`/${packageName('linux-x64').replace('/', '%2F')}/${VERSION}`, serve('{"version":"9.9.9"}')); + assert.equal(await isPublished('linux-x64', VERSION, base), true); + assert.equal(await isPublished('darwin-x64', VERSION, base), false); + }); +});