ktx/scripts/release-readiness.mjs
Andrey Avtomonov 9dad936ac7
feat: npm-managed Python runtime for @kaelio/ktx (#7)
* docs: add npm managed python runtime design

* build: add bundled python runtime wheel builder

* build: make local embedding dependencies optional

* build: bundle python runtime wheel in cli artifacts

* build: track bundled python runtime release artifact

* test: verify bundled python runtime wheel

* docs: add plan for bundled python runtime wheel

* test: cover managed python runtime lifecycle

* feat: add managed python runtime installer

* feat: add runtime command runner

* feat: expose runtime management commands

* test: verify managed python runtime commands

* docs: add plan for managed python runtime installer

* feat: add managed python command helper

* feat: use managed runtime for sl query compute

* feat: route sl query managed runtime policy

* docs: add plan for managed runtime sl query integration

* feat: add managed runtime daemon metadata

* feat: manage python daemon lifecycle

* feat: add runtime daemon start stop commands

* fix: verify managed runtime daemon lifecycle

* docs: add plan for managed runtime daemon lifecycle

* feat: add managed local embeddings config marker

* feat: add managed local embeddings daemon helper

* feat: use managed runtime for local embedding setup

* feat: pass managed runtime policy through setup

* docs: add plan for managed local embeddings runtime

* feat: read CLI package metadata dynamically

* feat: assemble public kaelio ktx npm package

* feat: release one public kaelio ktx npm artifact

* test: cover public kaelio ktx package invocations

* chore: verify public kaelio ktx package artifacts

* docs: add plan for public kaelio ktx npm package

* test: verify managed runtime in public package smoke

* test: finalize managed runtime release smoke

* docs: add plan for managed runtime release smoke

* test: specify local embeddings release smoke

* feat: add local embeddings runtime smoke

* chore: register local embeddings smoke

* fix: verify local embeddings smoke

* fix: restore artifact smoke python env helper

* docs: add plan for managed local embeddings release smoke

* refactor: share managed runtime install policy parsing

* feat: use managed runtime for agent semantic queries

* feat: use managed runtime for MCP semantic compute

* docs: add plan for managed agent and MCP semantic runtime

* feat(cli): add managed daemon HTTP helpers

* feat(cli): route local adapters through managed daemon

* feat(cli): use managed daemon for ingest helpers

* feat(cli): pass managed daemon options to scan

* feat(context): pass MCP ingest pull config options

* feat(cli): pass managed daemon options to serve ingest

* test: verify managed local ingest daemon runtime

* docs: add plan for managed local ingest daemon runtime

* docs: align managed runtime examples

* docs: add plan for managed runtime docs cleanup

* test: cover published package runtime smoke commands

* test: validate published package smoke outputs

* docs: add plan for published package runtime smoke

* build: stamp public npm package version

* release: add npm public release policy

* release: add guarded npm publish script

* release: document public npm release handoff

* docs: add plan for public npm release handoff

* test: cover managed runtime prune in package smoke

* docs: document managed runtime prune

* docs: add plan for managed runtime prune smoke and docs

* chore: encode uv runtime prerequisite policy

* fix: clarify missing uv runtime error

* docs: document uv runtime prerequisite

* docs: add plan for uv runtime prerequisite contract

* refactor: limit release artifacts to public package runtime

* chore: align release policy with bundled runtime wheel

* docs: describe single public runtime artifact surface

* test: verify single public runtime artifact contract

* docs: add plan for single public runtime artifact cleanup

* fix: align local embeddings smoke with public version

* docs: add plan for local embeddings smoke public version

* release: soft-launch as @kaelio/ktx@0.1.0-rc.0 on next tag

Publish target moves to the pre-release version 0.1.0-rc.0 under the next
dist-tag so npm install @kaelio/ktx (which resolves to latest) does not
pick up the soft-launch build. Users opt in via @kaelio/ktx@next.

* Fix release script boundary checks

* Remove PostHog from public package bundle
2026-05-11 15:50:34 +02:00

359 lines
14 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 { PUBLIC_NPM_PACKAGE_VERSION } from './build-public-npm-package.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) {
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') {
const isPublicKtxPackage = entry.packageName === '@kaelio/ktx';
if (isPublicKtxPackage) {
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`);
} 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);
if (policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE) {
assertNpmPublicReleaseReadyPolicy(policy, metadata);
} else {
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),
runtimeInstaller: policy.runtimeInstaller,
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,
};
}
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;
}
}