release: add npm public release policy

This commit is contained in:
Andrey Avtomonov 2026-05-11 13:07:10 +02:00
parent 34f5a544e3
commit 81674c3017
3 changed files with 220 additions and 18 deletions

View file

@ -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": []
}

View file

@ -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`);

View file

@ -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 {