From 0861436f8106144ef0ae30ff7d31db5b9fc584ef Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Mon, 11 May 2026 13:09:27 +0200 Subject: [PATCH] release: add guarded npm publish script --- package.json | 1 + scripts/publish-public-npm-package.mjs | 87 ++++++++++++++++ scripts/publish-public-npm-package.test.mjs | 109 ++++++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 scripts/publish-public-npm-package.mjs create mode 100644 scripts/publish-public-npm-package.test.mjs diff --git a/package.json b/package.json index 2dd2b9c4..4d03cafc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/publish-public-npm-package.mjs b/scripts/publish-public-npm-package.mjs new file mode 100644 index 00000000..d82a157d --- /dev/null +++ b/scripts/publish-public-npm-package.mjs @@ -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; + } +} diff --git a/scripts/publish-public-npm-package.test.mjs b/scripts/publish-public-npm-package.test.mjs new file mode 100644 index 00000000..93bed72e --- /dev/null +++ b/scripts/publish-public-npm-package.test.mjs @@ -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'); + }); +});