From 81674c3017b075084dac1390b2b448cfec36e0d6 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Mon, 11 May 2026 13:07:10 +0200 Subject: [PATCH] release: add npm public release policy --- release-policy.json | 14 ++-- scripts/release-readiness.mjs | 102 +++++++++++++++++++++--- scripts/release-readiness.test.mjs | 122 +++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 18 deletions(-) diff --git a/release-policy.json b/release-policy.json index c19948f2..a6111c5f 100644 --- a/release-policy.json +++ b/release-policy.json @@ -1,9 +1,11 @@ { "schemaVersion": 1, - "releaseMode": "ci-artifact-only", + "releaseMode": "npm-public-release-ready", "npm": { - "publish": false, + "publish": true, "registry": null, + "access": "public", + "tag": "latest", "packages": ["@kaelio/ktx"] }, "python": { @@ -13,12 +15,8 @@ }, "publishedPackageSmoke": { "packageName": "@kaelio/ktx", - "version": "latest", + "version": "0.1.0", "registry": null }, - "requiredBeforePublishing": [ - "Choose public release version.", - "Configure registry credentials outside source control.", - "Choose release tag and provenance policy." - ] + "requiredBeforePublishing": [] } diff --git a/scripts/release-readiness.mjs b/scripts/release-readiness.mjs index 39814aae..047ed43f 100644 --- a/scripts/release-readiness.mjs +++ b/scripts/release-readiness.mjs @@ -5,6 +5,7 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { packageArtifactLayout, packageReleaseMetadata, verifyArtifactManifest } from './package-artifacts.mjs'; +import { PUBLIC_NPM_PACKAGE_VERSION } from './build-public-npm-package.mjs'; import { readPublishedPackageSmokeConfig } from './published-package-smoke-config.mjs'; function scriptRootDir() { @@ -21,9 +22,11 @@ async function readJson(path) { const CI_ARTIFACT_ONLY_RELEASE_MODE = 'ci-artifact-only'; const PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE = 'published-package-smoke-required'; +const NPM_PUBLIC_RELEASE_READY_MODE = 'npm-public-release-ready'; const SUPPORTED_RELEASE_MODES = new Set([ CI_ARTIFACT_ONLY_RELEASE_MODE, PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE, + NPM_PUBLIC_RELEASE_READY_MODE, ]); export async function readReleasePolicy(rootDir = scriptRootDir()) { @@ -64,6 +67,19 @@ function assertStringArray(value, label) { } } +function assertNpmAccess(value) { + if (value !== 'public') { + throw new Error('Release policy npm.access must be public'); + } +} + +function assertNpmTag(value) { + assertString(value, 'Release policy npm.tag'); + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(value)) { + throw new Error(`Invalid Release policy npm.tag: ${value}`); + } +} + function assertSupportedReleaseMode(releaseMode) { assertString(releaseMode, 'Release policy releaseMode'); if (!SUPPORTED_RELEASE_MODES.has(releaseMode)) { @@ -79,10 +95,11 @@ function assertRequiredBeforePublishing(policy) { } if ( - policy.releaseMode === PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE && + (policy.releaseMode === PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE || + policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE) && policy.requiredBeforePublishing.length > 0 ) { - throw new Error('published-package-smoke-required release mode requires requiredBeforePublishing to be empty'); + throw new Error(`${policy.releaseMode} release mode requires requiredBeforePublishing to be empty`); } } @@ -107,6 +124,8 @@ export function validateReleasePolicy(policy) { assertBoolean(policy.npm.publish, 'Release policy npm.publish'); assertNullableString(policy.npm.registry, 'Release policy npm.registry'); + assertNpmAccess(policy.npm.access); + assertNpmTag(policy.npm.tag); assertStringArray(policy.npm.packages, 'Release policy npm.packages'); assertBoolean(policy.python.publish, 'Release policy python.publish'); @@ -128,10 +147,12 @@ function metadataNames(metadata, ecosystem) { function publishedPackageSmokeGate(policy) { const config = readPublishedPackageSmokeConfig({}, [], policy.publishedPackageSmoke); - if (policy.releaseMode === PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE && !config.enabled) { - throw new Error( - 'published-package-smoke-required release mode requires release-policy.json publishedPackageSmoke.packageName', - ); + if ( + (policy.releaseMode === PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE || + policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE) && + !config.enabled + ) { + throw new Error(`${policy.releaseMode} release mode requires release-policy.json publishedPackageSmoke.packageName`); } const base = @@ -140,6 +161,11 @@ function publishedPackageSmokeGate(policy) { status: 'not_required', reason: 'Published package smoke remains pending until release-policy.json enables npm registry publishing.', } + : policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE + ? { + status: 'required', + reason: 'Run the published package smoke after the npm package is published.', + } : { status: 'required', reason: 'Run the published package smoke before accepting the hybrid-search release.', @@ -185,23 +211,63 @@ function assertNonPublishingArtifactPolicy(policy, metadata) { if (entry.private !== false) { throw new Error(`${policyLabel} npm package @kaelio/ktx must be publishable when npm.publish is false`); } + if (entry.packageVersion !== PUBLIC_NPM_PACKAGE_VERSION) { + throw new Error(`${policyLabel} npm package @kaelio/ktx must use public version ${PUBLIC_NPM_PACKAGE_VERSION}`); + } } else if (entry.private !== true) { throw new Error(`${policyLabel} npm package ${entry.packageName} must remain private`); - } - if (!entry.packageVersion.endsWith('-private')) { + } else if (!entry.packageVersion.endsWith('-private')) { throw new Error(`${policyLabel} npm package ${entry.packageName} must use a private version suffix`); } } } } +function assertNpmPublicReleaseReadyPolicy(policy, metadata) { + if (policy.npm.publish !== true) { + throw new Error('npm-public-release-ready policy requires npm.publish true'); + } + if (policy.python.publish !== false) { + throw new Error('npm-public-release-ready policy keeps python.publish false'); + } + if (policy.python.repository !== null) { + throw new Error('npm-public-release-ready policy keeps python.repository null'); + } + + assertSameMembers(policy.npm.packages, ['@kaelio/ktx'], 'Release policy npm.packages'); + assertSameMembers(policy.python.packages, metadataNames(metadata, 'python'), 'Release policy python.packages'); + + const npmMetadata = metadata.find((entry) => entry.ecosystem === 'npm' && entry.packageName === '@kaelio/ktx'); + if (!npmMetadata) { + throw new Error('npm-public-release-ready policy requires @kaelio/ktx artifact metadata'); + } + if (npmMetadata.private !== false) { + throw new Error('npm-public-release-ready policy requires @kaelio/ktx to be publishable'); + } + if (npmMetadata.packageVersion !== PUBLIC_NPM_PACKAGE_VERSION) { + throw new Error( + `npm-public-release-ready policy expected @kaelio/ktx ${PUBLIC_NPM_PACKAGE_VERSION}, got ${npmMetadata.packageVersion}`, + ); + } + if (policy.publishedPackageSmoke.packageName !== '@kaelio/ktx') { + throw new Error('npm-public-release-ready policy requires publishedPackageSmoke.packageName @kaelio/ktx'); + } + if (policy.publishedPackageSmoke.version !== PUBLIC_NPM_PACKAGE_VERSION) { + throw new Error(`npm-public-release-ready policy requires publishedPackageSmoke.version ${PUBLIC_NPM_PACKAGE_VERSION}`); + } +} + export async function releaseReadinessReport(rootDir = scriptRootDir()) { const policy = validateReleasePolicy(await readReleasePolicy(rootDir)); const layout = packageArtifactLayout(rootDir); const manifest = await verifyArtifactManifest(layout); const metadata = await packageReleaseMetadata(rootDir); - assertNonPublishingArtifactPolicy(policy, metadata); + if (policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE) { + assertNpmPublicReleaseReadyPolicy(policy, metadata); + } else { + assertNonPublishingArtifactPolicy(policy, metadata); + } return { schemaVersion: 1, @@ -211,6 +277,16 @@ export async function releaseReadinessReport(rootDir = scriptRootDir()) { pythonPublishEnabled: policy.python.publish, packageNames: metadata.map((entry) => entry.packageName), publishedPackageSmokeGate: publishedPackageSmokeGate(policy), + npmPublish: + policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE + ? { + packageName: '@kaelio/ktx', + version: PUBLIC_NPM_PACKAGE_VERSION, + access: policy.npm.access, + tag: policy.npm.tag, + registry: policy.npm.registry, + } + : null, blockedPublishingDecisions: policy.requiredBeforePublishing, }; } @@ -234,7 +310,13 @@ async function main() { process.stdout.write( `Published package smoke registry: ${report.publishedPackageSmokeGate.registry ?? 'default npm registry'}\n`, ); - process.stdout.write('Registry publishing remains disabled by release-policy.json.\n'); + if (report.npmPublish) { + process.stdout.write( + `NPM publish target: ${report.npmPublish.packageName}@${report.npmPublish.version} (${report.npmPublish.tag})\n`, + ); + } else { + process.stdout.write('Registry publishing remains disabled by release-policy.json.\n'); + } process.stdout.write('Required decisions before publishing:\n'); for (const decision of report.blockedPublishingDecisions) { process.stdout.write(`- ${decision}\n`); diff --git a/scripts/release-readiness.test.mjs b/scripts/release-readiness.test.mjs index 08a5c2a7..fba913c6 100644 --- a/scripts/release-readiness.test.mjs +++ b/scripts/release-readiness.test.mjs @@ -10,6 +10,7 @@ import { packageArtifactLayout, writeArtifactManifest, } from './package-artifacts.mjs'; +import { PUBLIC_NPM_PACKAGE_VERSION } from './build-public-npm-package.mjs'; import { readReleasePolicy, releasePolicyPath, releaseReadinessReport } from './release-readiness.mjs'; async function writeJson(path, value) { @@ -69,6 +70,8 @@ function releasePolicy(overrides = {}) { npm: { publish: false, registry: null, + access: 'public', + tag: 'latest', packages: ['@kaelio/ktx'], ...npmOverrides, }, @@ -144,6 +147,7 @@ describe('release readiness policy', () => { version: 'latest', registry: null, }, + npmPublish: null, blockedPublishingDecisions: [ 'Choose public release version.', 'Configure registry credentials outside source control.', @@ -217,6 +221,7 @@ describe('release readiness policy', () => { version: '2026.5.8', registry: 'https://registry.npmjs.org/', }, + npmPublish: null, blockedPublishingDecisions: [], }); } finally { @@ -224,6 +229,123 @@ describe('release readiness policy', () => { } }); + it('accepts the npm public release ready policy', async () => { + const root = await mkdtemp(join(tmpdir(), 'ktx-npm-public-ready-test-')); + try { + await writeReadyFixture(root, { + policy: releasePolicy({ + releaseMode: 'npm-public-release-ready', + npm: { + publish: true, + registry: null, + access: 'public', + tag: 'latest', + }, + publishedPackageSmoke: { + packageName: '@kaelio/ktx', + version: PUBLIC_NPM_PACKAGE_VERSION, + registry: null, + }, + requiredBeforePublishing: [], + }), + }); + + const report = await releaseReadinessReport(root); + + assert.deepEqual(report, { + schemaVersion: 1, + releaseMode: 'npm-public-release-ready', + sourceRevision: 'abc123', + npmPublishEnabled: true, + pythonPublishEnabled: false, + packageNames: ['@kaelio/ktx', 'ktx-sl', 'ktx-daemon', 'kaelio-ktx'], + publishedPackageSmokeGate: { + status: 'required', + script: 'pnpm run release:published-smoke', + reason: 'Run the published package smoke after the npm package is published.', + configSource: 'release-policy', + packageName: '@kaelio/ktx', + version: PUBLIC_NPM_PACKAGE_VERSION, + registry: null, + }, + npmPublish: { + packageName: '@kaelio/ktx', + version: PUBLIC_NPM_PACKAGE_VERSION, + access: 'public', + tag: 'latest', + registry: null, + }, + blockedPublishingDecisions: [], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('rejects npm public release ready mode when npm publish is disabled', async () => { + const root = await mkdtemp(join(tmpdir(), 'ktx-npm-public-ready-disabled-test-')); + try { + await writeReadyFixture(root, { + policy: releasePolicy({ + releaseMode: 'npm-public-release-ready', + npm: { + publish: false, + registry: null, + access: 'public', + tag: 'latest', + }, + publishedPackageSmoke: { + packageName: '@kaelio/ktx', + version: PUBLIC_NPM_PACKAGE_VERSION, + registry: null, + }, + requiredBeforePublishing: [], + }), + }); + + await assert.rejects( + () => releaseReadinessReport(root), + /npm-public-release-ready policy requires npm.publish true/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('rejects npm public release ready mode when Python publishing is enabled', async () => { + const root = await mkdtemp(join(tmpdir(), 'ktx-npm-public-ready-python-test-')); + try { + await writeReadyFixture(root, { + policy: releasePolicy({ + releaseMode: 'npm-public-release-ready', + npm: { + publish: true, + registry: null, + access: 'public', + tag: 'latest', + }, + python: { + publish: true, + repository: 'pypi', + }, + publishedPackageSmoke: { + packageName: '@kaelio/ktx', + version: PUBLIC_NPM_PACKAGE_VERSION, + registry: null, + }, + requiredBeforePublishing: [], + }), + }); + + await assert.rejects( + () => releaseReadinessReport(root), + /npm-public-release-ready policy keeps python.publish false/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it('rejects required published smoke mode without a package name', async () => { const root = await mkdtemp(join(tmpdir(), 'ktx-release-smoke-required-missing-config-test-')); try {