release: add guarded npm publish script

This commit is contained in:
Andrey Avtomonov 2026-05-11 13:09:27 +02:00
parent 81674c3017
commit 0861436f81
3 changed files with 197 additions and 0 deletions

View file

@ -24,6 +24,7 @@
"setup:dev": "node scripts/setup-dev.mjs",
"release:published-smoke": "node scripts/published-package-smoke.mjs --require-config",
"release:local-embeddings-smoke": "node scripts/local-embeddings-runtime-smoke.mjs --require-opt-in",
"release:npm-publish": "node scripts/publish-public-npm-package.mjs",
"release:readiness": "node scripts/release-readiness.mjs",
"relationships:acquire-public-fixtures": "node scripts/acquire-public-benchmark-fixtures.mjs",
"relationships:rebuild-public-snapshots": "node scripts/build-benchmark-snapshot.mjs --rebuild-all",

View file

@ -0,0 +1,87 @@
#!/usr/bin/env node
import { execFile } from 'node:child_process';
import { access } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import { promisify } from 'node:util';
import { packageArtifactLayout } from './package-artifacts.mjs';
import { releaseReadinessReport } from './release-readiness.mjs';
const execFileAsync = promisify(execFile);
export function resolvePublishMode(args = process.argv.slice(2)) {
return { live: args.includes('--publish') };
}
export function requireNpmPublicReleaseReady(report) {
if (report.releaseMode !== 'npm-public-release-ready' || report.npmPublishEnabled !== true || !report.npmPublish) {
throw new Error('release-policy.json must use npm-public-release-ready before publishing');
}
return report.npmPublish;
}
export function buildNpmPublishCommand(tarballPath, publish, mode) {
return {
command: 'pnpm',
args: [
'publish',
tarballPath,
'--access',
publish.access,
'--tag',
publish.tag,
...(mode.live ? [] : ['--dry-run', '--no-git-checks']),
],
env: publish.registry ? { npm_config_registry: publish.registry } : {},
};
}
async function assertFileExists(path) {
try {
await access(path);
} catch {
throw new Error(`Missing npm tarball: ${path}. Run pnpm run artifacts:check first.`);
}
}
async function runPublishCommand(command) {
process.stdout.write(`$ ${command.command} ${command.args.join(' ')}\n`);
await execFileAsync(command.command, command.args, {
env: { ...process.env, ...command.env },
encoding: 'utf8',
maxBuffer: 1024 * 1024 * 20,
});
}
export async function publishPublicNpmPackage(options = {}) {
const rootDir = options.rootDir;
const mode = options.mode ?? resolvePublishMode(options.args);
const report = await releaseReadinessReport(rootDir);
const publish = requireNpmPublicReleaseReady(report);
const layout = packageArtifactLayout(rootDir);
const tarballPath = layout.cliTarball;
await assertFileExists(tarballPath);
const command = buildNpmPublishCommand(tarballPath, publish, mode);
await runPublishCommand(command);
process.stdout.write(
mode.live
? `Published ${publish.packageName}@${publish.version} with tag ${publish.tag}\n`
: `Dry-run verified ${publish.packageName}@${publish.version} with tag ${publish.tag}\n`,
);
}
async function main() {
await publishPublicNpmPackage({ args: process.argv.slice(2) });
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
try {
await main();
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
process.exitCode = 1;
}
}

View file

@ -0,0 +1,109 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { describe, it } from 'node:test';
import {
buildNpmPublishCommand,
requireNpmPublicReleaseReady,
resolvePublishMode,
} from './publish-public-npm-package.mjs';
const readyReport = {
releaseMode: 'npm-public-release-ready',
npmPublishEnabled: true,
npmPublish: {
packageName: '@kaelio/ktx',
version: '0.1.0',
access: 'public',
tag: 'latest',
registry: null,
},
};
describe('resolvePublishMode', () => {
it('dry-runs by default', () => {
assert.deepEqual(resolvePublishMode([]), { live: false });
});
it('requires an explicit flag for live publish', () => {
assert.deepEqual(resolvePublishMode(['--publish']), { live: true });
});
});
describe('requireNpmPublicReleaseReady', () => {
it('accepts the npm public release ready report', () => {
assert.equal(requireNpmPublicReleaseReady(readyReport), readyReport.npmPublish);
});
it('rejects artifact-only reports', () => {
assert.throws(
() =>
requireNpmPublicReleaseReady({
releaseMode: 'ci-artifact-only',
npmPublishEnabled: false,
npmPublish: null,
}),
/release-policy.json must use npm-public-release-ready before publishing/,
);
});
});
describe('buildNpmPublishCommand', () => {
it('builds a dry-run pnpm publish command by default', () => {
assert.deepEqual(
buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz', readyReport.npmPublish, {
live: false,
}),
{
command: 'pnpm',
args: [
'publish',
'/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz',
'--access',
'public',
'--tag',
'latest',
'--dry-run',
'--no-git-checks',
],
env: {},
},
);
});
it('omits dry-run only for explicit live publish', () => {
assert.deepEqual(
buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz', readyReport.npmPublish, {
live: true,
}).args,
[
'publish',
'/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz',
'--access',
'public',
'--tag',
'latest',
],
);
});
it('uses npm_config_registry when a registry is configured', () => {
const publish = {
...readyReport.npmPublish,
registry: 'https://registry.npmjs.org/',
};
assert.deepEqual(
buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz', publish, { live: false }).env,
{ npm_config_registry: 'https://registry.npmjs.org/' },
);
});
});
describe('package script', () => {
it('registers release:npm-publish', async () => {
const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
assert.equal(packageJson.scripts['release:npm-publish'], 'node scripts/publish-public-npm-package.mjs');
});
});