ktx/scripts/release-readiness.mjs
2026-05-10 23:51:24 +02:00

246 lines
9.5 KiB
JavaScript

#!/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 { 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 SUPPORTED_RELEASE_MODES = new Set([
CI_ARTIFACT_ONLY_RELEASE_MODE,
PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_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 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.requiredBeforePublishing.length > 0
) {
throw new Error('published-package-smoke-required release mode requires requiredBeforePublishing to be empty');
}
}
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');
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);
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 && !config.enabled) {
throw new Error(
'published-package-smoke-required 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.',
}
: {
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) {
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') {
if (entry.private !== true) {
throw new Error(`${policyLabel} npm package ${entry.packageName} must remain private`);
}
if (!entry.packageVersion.endsWith('-private')) {
throw new Error(`${policyLabel} npm package ${entry.packageName} must use a private version suffix`);
}
}
}
}
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);
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),
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('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;
}
}