Release: publish the npm platform packages in one command

bun run release:platform-packages downloads each engine-v<ENGINE_VERSION>
binary with its .sha256 sidecar (required; nothing unverified is
published), stages the package from cli/platform-packages/<target> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
This commit is contained in:
Paul Bakaus
2026-09-04 10:12:17 -07:00
co-authored by Claude Code
parent 1e19e1838c
commit 2561232864
6 changed files with 333 additions and 3 deletions
+2 -2
View File
@@ -6,8 +6,8 @@ the one matching the host (`os` / `cpu` fields), and the shim resolves
`<package>/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`.
+1
View File
@@ -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": {
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env node
/**
* Publish the five @impeccable/cli-<os>-<arch> npm platform packages for the
* pinned ENGINE_VERSION from the engine-v<ENGINE_VERSION> 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/<target>/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/<binary> (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 <name>@<version> 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 <os-arch>]...');
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);
});
}
+1 -1
View File
@@ -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-<os>-<arch> npm platform packages, then release the CLI/skill.');
console.log(' Then publish the five @impeccable/cli-<os>-<arch> npm platform packages with `bun run release:platform-packages`, then release the CLI/skill.');
}
+1
View File
@@ -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',
+113
View File
@@ -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/<binary> 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);
});
});