mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
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:
co-authored by
Claude Code
parent
1e19e1838c
commit
2561232864
@@ -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`.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user