#!/usr/bin/env node import { readFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { packageArtifactLayout, packageReleaseMetadata, verifyArtifactManifest } from './package-artifacts.mjs'; import { publicNpmPackageVersion } from './public-npm-release-metadata.mjs'; import { readPublishedPackageSmokeConfig } from './published-package-smoke-config.mjs'; function scriptRootDir() { return resolve(dirname(fileURLToPath(import.meta.url)), '..'); } export function releasePolicyPath(rootDir = scriptRootDir()) { return join(rootDir, 'release-policy.json'); } async function readJson(path) { return JSON.parse(await readFile(path, 'utf-8')); } 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()) { return readJson(releasePolicyPath(rootDir)); } function isPlainObject(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } function assertPlainObject(value, label) { if (!isPlainObject(value)) { throw new Error(`${label} must be a JSON object`); } } function assertBoolean(value, label) { if (typeof value !== 'boolean') { throw new Error(`${label} must be a boolean`); } } function assertString(value, label) { if (typeof value !== 'string') { throw new Error(`${label} must be a string`); } } function assertNullableString(value, label) { if (value !== null && typeof value !== 'string') { throw new Error(`${label} must be a string or null`); } } function assertStringArray(value, label) { if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) { throw new Error(`${label} must be an array of strings`); } } 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)) { throw new Error(`Unsupported release policy releaseMode: ${releaseMode}`); } } function assertRequiredBeforePublishing(policy) { assertStringArray(policy.requiredBeforePublishing, 'Release policy requiredBeforePublishing'); if (policy.releaseMode === CI_ARTIFACT_ONLY_RELEASE_MODE && policy.requiredBeforePublishing.length === 0) { throw new Error('Release policy requiredBeforePublishing must list the remaining publishing decisions'); } if ( (policy.releaseMode === PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE || policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE) && policy.requiredBeforePublishing.length > 0 ) { throw new Error(`${policy.releaseMode} release mode requires requiredBeforePublishing to be empty`); } } function assertRuntimeInstallerPolicy(policy) { assertPlainObject(policy.runtimeInstaller, 'Release policy runtimeInstaller'); assertString(policy.runtimeInstaller.uvStrategy, 'Release policy runtimeInstaller.uvStrategy'); assertBoolean(policy.runtimeInstaller.bootstrapUv, 'Release policy runtimeInstaller.bootstrapUv'); assertString( policy.runtimeInstaller.missingUvBehavior, 'Release policy runtimeInstaller.missingUvBehavior', ); if (policy.runtimeInstaller.uvStrategy !== 'path-prerequisite') { throw new Error('Release policy runtimeInstaller.uvStrategy must be path-prerequisite'); } if (policy.runtimeInstaller.bootstrapUv !== false) { throw new Error('Release policy runtimeInstaller.bootstrapUv must be false'); } if (policy.runtimeInstaller.missingUvBehavior !== 'focused-error') { throw new Error('Release policy runtimeInstaller.missingUvBehavior must be focused-error'); } } function assertSameMembers(actual, expected, label) { const sortedActual = [...actual].sort(); const sortedExpected = [...expected].sort(); if (JSON.stringify(sortedActual) !== JSON.stringify(sortedExpected)) { throw new Error(`${label} mismatch: expected ${sortedExpected.join(', ')}, got ${sortedActual.join(', ')}`); } } export function validateReleasePolicy(policy) { assertPlainObject(policy, 'Release policy'); if (policy.schemaVersion !== 1) { throw new Error(`Unsupported release policy schemaVersion: ${policy.schemaVersion}`); } assertSupportedReleaseMode(policy.releaseMode); assertPlainObject(policy.npm, 'Release policy npm'); assertPlainObject(policy.python, 'Release policy python'); assertPlainObject(policy.publishedPackageSmoke, 'Release policy publishedPackageSmoke'); 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'); assertNullableString(policy.python.repository, 'Release policy python.repository'); assertStringArray(policy.python.packages, 'Release policy python.packages'); assertNullableString(policy.publishedPackageSmoke.packageName, 'Release policy publishedPackageSmoke.packageName'); assertString(policy.publishedPackageSmoke.version, 'Release policy publishedPackageSmoke.version'); assertNullableString(policy.publishedPackageSmoke.registry, 'Release policy publishedPackageSmoke.registry'); readPublishedPackageSmokeConfig({}, [], policy.publishedPackageSmoke); assertRequiredBeforePublishing(policy); assertRuntimeInstallerPolicy(policy); return policy; } function metadataNames(metadata, ecosystem) { return metadata.filter((entry) => entry.ecosystem === ecosystem).map((entry) => entry.packageName); } function publishedPackageSmokeGate(policy) { const config = readPublishedPackageSmokeConfig({}, [], policy.publishedPackageSmoke); 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 = policy.releaseMode === CI_ARTIFACT_ONLY_RELEASE_MODE ? { 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.', }; return { ...base, script: 'pnpm run release:published-smoke', configSource: config.enabled ? config.configSource : null, packageName: config.enabled ? config.packageName : null, version: config.enabled ? config.packageVersion : policy.publishedPackageSmoke.version, registry: config.enabled ? (config.registry ?? null) : policy.publishedPackageSmoke.registry, }; } function assertNonPublishingArtifactPolicy(policy, metadata, publicPackageVersion) { const policyLabel = policy.releaseMode === CI_ARTIFACT_ONLY_RELEASE_MODE ? 'ci-artifact-only policy' : `${policy.releaseMode} policy`; if (policy.npm.publish !== false) { throw new Error(`${policyLabel} must keep npm.publish false`); } if (policy.python.publish !== false) { throw new Error(`${policyLabel} must keep python.publish false`); } if (policy.npm.registry !== null) { throw new Error(`${policyLabel} must keep npm.registry null`); } if (policy.python.repository !== null) { throw new Error(`${policyLabel} must keep python.repository null`); } assertSameMembers(policy.npm.packages, metadataNames(metadata, 'npm'), 'Release policy npm.packages'); assertSameMembers(policy.python.packages, metadataNames(metadata, 'python'), 'Release policy python.packages'); for (const entry of metadata) { if (entry.releaseMode !== CI_ARTIFACT_ONLY_RELEASE_MODE) { throw new Error(`Package ${entry.packageName} releaseMode must remain ci-artifact-only`); } if (entry.ecosystem !== 'npm') { continue; } if (entry.packageName !== '@kaelio/ktx') { throw new Error(`${policyLabel} unexpected npm package ${entry.packageName}`); } if (entry.private !== false) { throw new Error(`${policyLabel} npm package @kaelio/ktx must be publishable when npm.publish is false`); } if (entry.packageVersion !== publicPackageVersion) { throw new Error(`${policyLabel} npm package @kaelio/ktx must use public version ${publicPackageVersion}`); } } } function assertNpmPublicReleaseReadyPolicy(policy, metadata, publicPackageVersion) { 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 !== publicPackageVersion) { throw new Error( `npm-public-release-ready policy expected @kaelio/ktx ${publicPackageVersion}, 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 !== publicPackageVersion) { throw new Error(`npm-public-release-ready policy requires publishedPackageSmoke.version ${publicPackageVersion}`); } } export async function releaseReadinessReport(rootDir = scriptRootDir()) { const policy = validateReleasePolicy(await readReleasePolicy(rootDir)); const publicPackageVersion = publicNpmPackageVersion(rootDir); const layout = packageArtifactLayout(rootDir, publicPackageVersion); const manifest = await verifyArtifactManifest(layout); const metadata = await packageReleaseMetadata(rootDir, publicPackageVersion); if (policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE) { assertNpmPublicReleaseReadyPolicy(policy, metadata, publicPackageVersion); } else { assertNonPublishingArtifactPolicy(policy, metadata, publicPackageVersion); } return { schemaVersion: 1, releaseMode: policy.releaseMode, sourceRevision: manifest.sourceRevision, npmPublishEnabled: policy.npm.publish, pythonPublishEnabled: policy.python.publish, packageNames: metadata.map((entry) => entry.packageName), publishedPackageSmokeGate: publishedPackageSmokeGate(policy), runtimeInstaller: policy.runtimeInstaller, npmPublish: policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE ? { packageName: '@kaelio/ktx', version: publicPackageVersion, access: policy.npm.access, tag: policy.npm.tag, registry: policy.npm.registry, } : null, blockedPublishingDecisions: policy.requiredBeforePublishing, }; } async function main() { const report = await releaseReadinessReport(); if (process.argv.includes('--json')) { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); return; } process.stdout.write(`KTX release mode: ${report.releaseMode}\n`); process.stdout.write(`KTX source revision: ${report.sourceRevision ?? 'local'}\n`); process.stdout.write(`KTX packages: ${report.packageNames.join(', ')}\n`); process.stdout.write(`Published package smoke: ${report.publishedPackageSmokeGate.status}\n`); process.stdout.write(`Published package smoke script: ${report.publishedPackageSmokeGate.script}\n`); process.stdout.write(`Published package smoke reason: ${report.publishedPackageSmokeGate.reason}\n`); process.stdout.write(`Published package smoke package: ${report.publishedPackageSmokeGate.packageName ?? 'not configured'}\n`); process.stdout.write(`Published package smoke version: ${report.publishedPackageSmokeGate.version}\n`); process.stdout.write( `Published package smoke registry: ${report.publishedPackageSmokeGate.registry ?? 'default npm registry'}\n`, ); process.stdout.write(`Runtime uv strategy: ${report.runtimeInstaller.uvStrategy}\n`); process.stdout.write( `Runtime uv bootstrap: ${report.runtimeInstaller.bootstrapUv ? 'enabled' : 'disabled'}\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`); } } 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; } }