feat: release one public kaelio ktx npm artifact

This commit is contained in:
Andrey Avtomonov 2026-05-11 11:22:43 +02:00
parent 24dbbe2a06
commit ba47ab95e7
5 changed files with 123 additions and 163 deletions

View file

@ -4,19 +4,7 @@
"npm": {
"publish": false,
"registry": null,
"packages": [
"@ktx/cli",
"@ktx/connector-bigquery",
"@ktx/connector-clickhouse",
"@ktx/connector-mysql",
"@ktx/connector-postgres",
"@ktx/connector-posthog",
"@ktx/connector-snowflake",
"@ktx/connector-sqlite",
"@ktx/connector-sqlserver",
"@ktx/context",
"@ktx/llm"
]
"packages": ["@kaelio/ktx"]
},
"python": {
"publish": false,
@ -24,14 +12,12 @@
"packages": ["ktx-sl", "ktx-daemon", "kaelio-ktx"]
},
"publishedPackageSmoke": {
"packageName": null,
"packageName": "@kaelio/ktx",
"version": "latest",
"registry": null
},
"requiredBeforePublishing": [
"Choose npm registry and package visibility.",
"Choose Python package repository.",
"Choose public release versions.",
"Choose public release version.",
"Configure registry credentials outside source control.",
"Choose release tag and provenance policy."
]

View file

@ -12,6 +12,10 @@ import {
RUNTIME_WHEEL_NORMALIZED_NAME,
RUNTIME_WHEEL_PACKAGE_VERSION,
} from './build-python-runtime-wheel.mjs';
import {
PUBLIC_NPM_PACKAGE_NAME,
PUBLIC_NPM_PACKAGE_TARBALL,
} from './build-public-npm-package.mjs';
const PACKAGE_VERSION = '0.0.0-private';
const PYTHON_PACKAGE_VERSION = '0.1.0';
@ -22,7 +26,7 @@ export {
RUNTIME_WHEEL_PACKAGE_VERSION,
};
export const NPM_ARTIFACT_PACKAGES = [
export const INTERNAL_NPM_WORKSPACE_PACKAGES = [
{ name: '@ktx/context', packageRoot: 'packages/context' },
{ name: '@ktx/llm', packageRoot: 'packages/llm' },
{ name: '@ktx/connector-bigquery', packageRoot: 'packages/connector-bigquery' },
@ -36,9 +40,11 @@ export const NPM_ARTIFACT_PACKAGES = [
{ name: '@ktx/cli', packageRoot: 'packages/cli' },
];
export const NPM_ARTIFACT_PACKAGES = [{ name: PUBLIC_NPM_PACKAGE_NAME, packageRoot: 'packages/cli' }];
export const CLI_PYTHON_ASSET_MANIFEST = 'manifest.json';
const CONNECTOR_PACKAGE_NAMES = NPM_ARTIFACT_PACKAGES
const CONNECTOR_PACKAGE_NAMES = INTERNAL_NPM_WORKSPACE_PACKAGES
.map((packageInfo) => packageInfo.name)
.filter((packageName) => packageName.startsWith('@ktx/connector-'));
@ -62,6 +68,9 @@ function scriptRootDir() {
}
function npmPackageTarballName(packageName) {
if (packageName === PUBLIC_NPM_PACKAGE_NAME) {
return PUBLIC_NPM_PACKAGE_TARBALL;
}
return `${packageName.replace('@ktx/', 'ktx-')}-${PACKAGE_VERSION}.tgz`;
}
@ -83,17 +92,15 @@ export function packageArtifactLayout(rootDir = scriptRootDir()) {
npmDir,
pythonDir,
npmTarballs,
contextTarball: npmTarballs['@ktx/context'],
cliTarball: npmTarballs['@ktx/cli'],
connectorTarballs: Object.fromEntries(
CONNECTOR_PACKAGE_NAMES.map((packageName) => [packageName, npmTarballs[packageName]]),
),
contextTarball: npmTarballs[PUBLIC_NPM_PACKAGE_NAME],
cliTarball: npmTarballs[PUBLIC_NPM_PACKAGE_NAME],
connectorTarballs: {},
manifestPath: join(artifactDir, 'manifest.json'),
};
}
export function buildArtifactCommands(layout) {
const packagesByName = new Map(NPM_ARTIFACT_PACKAGES.map((packageInfo) => [packageInfo.name, packageInfo]));
const packagesByName = new Map(INTERNAL_NPM_WORKSPACE_PACKAGES.map((packageInfo) => [packageInfo.name, packageInfo]));
const npmBuildCommands = NPM_ARTIFACT_BUILD_ORDER.map((packageName) => {
const packageInfo = packagesByName.get(packageName);
if (!packageInfo) {
@ -105,11 +112,11 @@ export function buildArtifactCommands(layout) {
cwd: layout.rootDir,
};
});
const npmPackCommands = NPM_ARTIFACT_PACKAGES.map((packageInfo) => ({
command: 'pnpm',
args: ['--filter', packageInfo.name, 'pack', '--out', layout.npmTarballs[packageInfo.name]],
const publicPackageCommand = {
command: process.execPath,
args: ['scripts/build-public-npm-package.mjs'],
cwd: layout.rootDir,
}));
};
return [
...npmBuildCommands,
@ -128,7 +135,7 @@ export function buildArtifactCommands(layout) {
args: ['build', '--package', 'ktx-daemon', '--out-dir', layout.pythonDir],
cwd: layout.rootDir,
},
...npmPackCommands,
publicPackageCommand,
];
}
@ -241,17 +248,18 @@ function releaseMetadataEntry({ ecosystem, packageName, packageRoot, packageVers
async function readNpmPackageMetadata(rootDir, packageInfo) {
const packageJson = await readJson(join(rootDir, packageInfo.packageRoot, 'package.json'));
if (packageJson.name !== packageInfo.name) {
const expectedSourceName = packageInfo.name === PUBLIC_NPM_PACKAGE_NAME ? '@ktx/cli' : packageInfo.name;
if (packageJson.name !== expectedSourceName) {
throw new Error(
`Unexpected package name in ${packageInfo.packageRoot}/package.json: expected ${packageInfo.name}, got ${packageJson.name}`,
`Unexpected package name in ${packageInfo.packageRoot}/package.json: expected ${expectedSourceName}, got ${packageJson.name}`,
);
}
return releaseMetadataEntry({
ecosystem: 'npm',
packageName: packageJson.name,
packageName: packageInfo.name,
packageRoot: packageInfo.packageRoot,
packageVersion: packageJson.version,
privatePackage: packageJson.private === true,
privatePackage: packageInfo.name === PUBLIC_NPM_PACKAGE_NAME ? false : packageJson.private === true,
});
}
@ -1623,8 +1631,8 @@ async function buildArtifacts(layout) {
await mkdir(layout.pythonDir, { recursive: true });
const commands = buildArtifactCommands(layout);
const npmBuildCount = NPM_ARTIFACT_PACKAGES.length;
const npmPackStart = commands.length - NPM_ARTIFACT_PACKAGES.length;
const npmBuildCount = NPM_ARTIFACT_BUILD_ORDER.length;
const npmPackStart = commands.length - 1;
for (const command of commands.slice(0, npmBuildCount)) {
await runCommand(command.command, command.args, { cwd: command.cwd });

View file

@ -7,6 +7,7 @@ import { describe, it } from 'node:test';
import {
CLI_PYTHON_ASSET_MANIFEST,
INTERNAL_NPM_WORKSPACE_PACKAGES,
RUNTIME_WHEEL_DISTRIBUTION_NAME,
RUNTIME_WHEEL_NORMALIZED_NAME,
RUNTIME_WHEEL_PACKAGE_VERSION,
@ -34,35 +35,17 @@ async function writeJson(path, value) {
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
}
const CONNECTOR_PACKAGE_NAMES = [
'@ktx/connector-bigquery',
'@ktx/connector-clickhouse',
'@ktx/connector-mysql',
'@ktx/connector-postgres',
'@ktx/connector-posthog',
'@ktx/connector-snowflake',
'@ktx/connector-sqlite',
'@ktx/connector-sqlserver',
];
const INTERNAL_BUILD_PACKAGE_NAMES = INTERNAL_NPM_WORKSPACE_PACKAGES.map((packageInfo) => packageInfo.name);
const CONNECTOR_PACKAGE_NAMES = INTERNAL_BUILD_PACKAGE_NAMES.filter((packageName) =>
packageName.startsWith('@ktx/connector-'),
);
const NPM_BUILD_PACKAGE_ORDER = ['@ktx/llm', '@ktx/context', ...CONNECTOR_PACKAGE_NAMES, '@ktx/cli'];
function packageRootForName(packageName) {
return `packages/${packageName.replace('@ktx/', '')}`;
}
function expectedNpmArtifactPath(packageName) {
return `npm/${packageName.replace('@ktx/', 'ktx-')}-0.0.0-private.tgz`;
}
async function writeReleaseMetadataInputs(root) {
const npmPackages = ['@ktx/context', '@ktx/llm', ...CONNECTOR_PACKAGE_NAMES, '@ktx/cli'];
for (const packageName of npmPackages) {
const packageRoot = packageName === '@ktx/context' ? 'packages/context' : packageRootForName(packageName);
await mkdir(join(root, packageRoot), { recursive: true });
await writeJson(join(root, packageRoot, 'package.json'), {
name: packageName,
for (const packageInfo of INTERNAL_NPM_WORKSPACE_PACKAGES) {
await mkdir(join(root, packageInfo.packageRoot), { recursive: true });
await writeJson(join(root, packageInfo.packageRoot, 'package.json'), {
name: packageInfo.name,
version: '0.0.0-private',
private: true,
});
@ -111,20 +94,8 @@ describe('packageArtifactLayout', () => {
assert.equal(layout.artifactDir, '/repo/ktx/dist/artifacts');
assert.equal(layout.npmDir, '/repo/ktx/dist/artifacts/npm');
assert.equal(layout.pythonDir, '/repo/ktx/dist/artifacts/python');
assert.equal(layout.contextTarball, '/repo/ktx/dist/artifacts/npm/ktx-context-0.0.0-private.tgz');
assert.equal(layout.cliTarball, '/repo/ktx/dist/artifacts/npm/ktx-cli-0.0.0-private.tgz');
assert.equal(
layout.connectorTarballs['@ktx/connector-sqlite'],
'/repo/ktx/dist/artifacts/npm/ktx-connector-sqlite-0.0.0-private.tgz',
);
assert.equal(
layout.connectorTarballs['@ktx/connector-postgres'],
'/repo/ktx/dist/artifacts/npm/ktx-connector-postgres-0.0.0-private.tgz',
);
assert.deepEqual(
Object.keys(layout.npmTarballs),
NPM_ARTIFACT_PACKAGES.map((packageInfo) => packageInfo.name),
);
assert.equal(layout.cliTarball, '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.0.0-private.tgz');
assert.deepEqual(Object.keys(layout.npmTarballs), ['@kaelio/ktx']);
});
});
@ -134,34 +105,23 @@ describe('buildArtifactCommands', () => {
const commands = buildArtifactCommands(layout);
assert.deepEqual(
commands.slice(0, NPM_ARTIFACT_PACKAGES.length).map((command) => [command.command, command.args]),
commands.slice(0, NPM_BUILD_PACKAGE_ORDER.length).map((command) => [command.command, command.args]),
NPM_BUILD_PACKAGE_ORDER.map((packageName) => ['pnpm', ['--filter', packageName, 'run', 'build']]),
);
assert.deepEqual(
commands
.slice(NPM_ARTIFACT_PACKAGES.length, NPM_ARTIFACT_PACKAGES.length + 3)
.map((command) => [command.command, command.args]),
commands.slice(NPM_BUILD_PACKAGE_ORDER.length, NPM_BUILD_PACKAGE_ORDER.length + 3).map((command) => [
command.command,
command.args,
]),
[
[
process.execPath,
['scripts/build-python-runtime-wheel.mjs'],
],
[
'uv',
['build', '--package', 'ktx-sl', '--out-dir', '/repo/ktx/dist/artifacts/python'],
],
[
'uv',
['build', '--package', 'ktx-daemon', '--out-dir', '/repo/ktx/dist/artifacts/python'],
],
[process.execPath, ['scripts/build-python-runtime-wheel.mjs']],
['uv', ['build', '--package', 'ktx-sl', '--out-dir', '/repo/ktx/dist/artifacts/python']],
['uv', ['build', '--package', 'ktx-daemon', '--out-dir', '/repo/ktx/dist/artifacts/python']],
],
);
assert.deepEqual(
commands.slice(NPM_ARTIFACT_PACKAGES.length + 3).map((command) => [command.command, command.args]),
NPM_ARTIFACT_PACKAGES.map((packageInfo) => [
'pnpm',
['--filter', packageInfo.name, 'pack', '--out', layout.npmTarballs[packageInfo.name]],
]),
commands.slice(NPM_BUILD_PACKAGE_ORDER.length + 3).map((command) => [command.command, command.args]),
[[process.execPath, ['scripts/build-public-npm-package.mjs']]],
);
});
});
@ -173,14 +133,14 @@ describe('packageReleaseMetadata', () => {
await writeReleaseMetadataInputs(root);
assert.deepEqual(await packageReleaseMetadata(root), [
...NPM_ARTIFACT_PACKAGES.map((packageInfo) => ({
{
ecosystem: 'npm',
packageName: packageInfo.name,
packageRoot: packageInfo.packageRoot,
packageName: '@kaelio/ktx',
packageRoot: 'packages/cli',
packageVersion: '0.0.0-private',
private: true,
private: false,
releaseMode: 'ci-artifact-only',
})),
},
{
ecosystem: 'python',
packageName: 'ktx-sl',
@ -262,14 +222,16 @@ describe('artifact manifest', () => {
assert.equal(manifest.sourceRevision, 'abc123');
assert.deepEqual(
manifest.packages.filter((entry) => entry.ecosystem === 'npm'),
NPM_ARTIFACT_PACKAGES.map((packageInfo) => ({
ecosystem: 'npm',
packageName: packageInfo.name,
packageRoot: packageInfo.packageRoot,
packageVersion: '0.0.0-private',
private: true,
releaseMode: 'ci-artifact-only',
})),
[
{
ecosystem: 'npm',
packageName: '@kaelio/ktx',
packageRoot: 'packages/cli',
packageVersion: '0.0.0-private',
private: false,
releaseMode: 'ci-artifact-only',
},
],
);
assert.deepEqual(
manifest.packages.filter((entry) => entry.ecosystem === 'python'),
@ -311,13 +273,15 @@ describe('artifact manifest', () => {
path: file.path,
}))
.sort((left, right) => left.packageName.localeCompare(right.packageName)),
NPM_ARTIFACT_PACKAGES.map((packageInfo) => ({
artifactKind: 'tarball',
ecosystem: 'npm',
packageName: packageInfo.name,
packageVersion: '0.0.0-private',
path: expectedNpmArtifactPath(packageInfo.name),
})).sort((left, right) => left.packageName.localeCompare(right.packageName)),
[
{
artifactKind: 'tarball',
ecosystem: 'npm',
packageName: '@kaelio/ktx',
packageVersion: '0.0.0-private',
path: 'npm/kaelio-ktx-0.0.0-private.tgz',
},
],
);
assert.deepEqual(
manifest.files
@ -368,10 +332,10 @@ describe('artifact manifest', () => {
],
);
const sqliteEntry = manifest.files.find((file) => file.path === 'npm/ktx-connector-sqlite-0.0.0-private.tgz');
assert.ok(sqliteEntry);
assert.equal(sqliteEntry.bytes, Buffer.byteLength('@ktx/connector-sqlite-tarball'));
assert.equal(sqliteEntry.sha256, createHash('sha256').update('@ktx/connector-sqlite-tarball').digest('hex'));
const npmEntry = manifest.files.find((file) => file.path === 'npm/kaelio-ktx-0.0.0-private.tgz');
assert.ok(npmEntry);
assert.equal(npmEntry.bytes, Buffer.byteLength('@kaelio/ktx-tarball'));
assert.equal(npmEntry.sha256, createHash('sha256').update('@kaelio/ktx-tarball').digest('hex'));
const writtenManifest = JSON.parse(await readFile(artifactManifestPath(layout), 'utf-8'));
assert.deepEqual(writtenManifest, manifest);

View file

@ -180,7 +180,12 @@ function assertNonPublishingArtifactPolicy(policy, metadata) {
throw new Error(`Package ${entry.packageName} releaseMode must remain ci-artifact-only`);
}
if (entry.ecosystem === 'npm') {
if (entry.private !== true) {
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`);
}
} else if (entry.private !== true) {
throw new Error(`${policyLabel} npm package ${entry.packageName} must remain private`);
}
if (!entry.packageVersion.endsWith('-private')) {

View file

@ -4,25 +4,25 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it } from 'node:test';
import { NPM_ARTIFACT_PACKAGES, packageArtifactLayout, writeArtifactManifest } from './package-artifacts.mjs';
import {
INTERNAL_NPM_WORKSPACE_PACKAGES,
NPM_ARTIFACT_PACKAGES,
packageArtifactLayout,
writeArtifactManifest,
} from './package-artifacts.mjs';
import { readReleasePolicy, releasePolicyPath, releaseReadinessReport } from './release-readiness.mjs';
async function writeJson(path, value) {
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
}
async function writeReleaseMetadataInputs(root, options = {}) {
for (const packageInfo of NPM_ARTIFACT_PACKAGES) {
async function writeReleaseMetadataInputs(root) {
for (const packageInfo of INTERNAL_NPM_WORKSPACE_PACKAGES) {
await mkdir(join(root, packageInfo.packageRoot), { recursive: true });
await writeJson(join(root, packageInfo.packageRoot, 'package.json'), {
name: packageInfo.name,
version: '0.0.0-private',
private:
packageInfo.name === '@ktx/context'
? (options.contextPrivate ?? true)
: packageInfo.name === '@ktx/cli'
? (options.cliPrivate ?? true)
: true,
private: true,
});
}
@ -69,7 +69,7 @@ function releasePolicy(overrides = {}) {
npm: {
publish: false,
registry: null,
packages: NPM_ARTIFACT_PACKAGES.map((packageInfo) => packageInfo.name),
packages: ['@kaelio/ktx'],
...npmOverrides,
},
python: {
@ -79,14 +79,12 @@ function releasePolicy(overrides = {}) {
...pythonOverrides,
},
publishedPackageSmoke: {
packageName: null,
packageName: '@kaelio/ktx',
version: 'latest',
registry: null,
},
requiredBeforePublishing: [
'Choose npm registry and package visibility.',
'Choose Python package repository.',
'Choose public release versions.',
'Choose public release version.',
'Configure registry credentials outside source control.',
'Choose release tag and provenance policy.',
],
@ -99,7 +97,7 @@ async function writePolicy(root, policy = releasePolicy()) {
}
async function writeReadyFixture(root, options = {}) {
await writeReleaseMetadataInputs(root, options);
await writeReleaseMetadataInputs(root);
await writePolicy(root, options.policy ?? releasePolicy());
const layout = packageArtifactLayout(root);
await writeUploadableArtifactFixtures(layout);
@ -136,25 +134,18 @@ describe('release readiness policy', () => {
sourceRevision: 'abc123',
npmPublishEnabled: false,
pythonPublishEnabled: false,
packageNames: [
...NPM_ARTIFACT_PACKAGES.map((packageInfo) => packageInfo.name),
'ktx-sl',
'ktx-daemon',
'kaelio-ktx',
],
packageNames: ['@kaelio/ktx', 'ktx-sl', 'ktx-daemon', 'kaelio-ktx'],
publishedPackageSmokeGate: {
status: 'not_required',
script: 'pnpm run release:published-smoke',
reason: 'Published package smoke remains pending until release-policy.json enables npm registry publishing.',
configSource: null,
packageName: null,
configSource: 'release-policy',
packageName: '@kaelio/ktx',
version: 'latest',
registry: null,
},
blockedPublishingDecisions: [
'Choose npm registry and package visibility.',
'Choose Python package repository.',
'Choose public release versions.',
'Choose public release version.',
'Configure registry credentials outside source control.',
'Choose release tag and provenance policy.',
],
@ -170,7 +161,7 @@ describe('release readiness policy', () => {
await writeReadyFixture(root, {
policy: releasePolicy({
publishedPackageSmoke: {
packageName: '@ktx/cli-public',
packageName: '@kaelio/ktx',
version: '2026.5.8',
registry: 'https://registry.npmjs.org/',
},
@ -184,7 +175,7 @@ describe('release readiness policy', () => {
script: 'pnpm run release:published-smoke',
reason: 'Published package smoke remains pending until release-policy.json enables npm registry publishing.',
configSource: 'release-policy',
packageName: '@ktx/cli-public',
packageName: '@kaelio/ktx',
version: '2026.5.8',
registry: 'https://registry.npmjs.org/',
});
@ -200,7 +191,7 @@ describe('release readiness policy', () => {
policy: releasePolicy({
releaseMode: 'published-package-smoke-required',
publishedPackageSmoke: {
packageName: '@ktx/cli-public',
packageName: '@kaelio/ktx',
version: '2026.5.8',
registry: 'https://registry.npmjs.org/',
},
@ -216,18 +207,13 @@ describe('release readiness policy', () => {
sourceRevision: 'abc123',
npmPublishEnabled: false,
pythonPublishEnabled: false,
packageNames: [
...NPM_ARTIFACT_PACKAGES.map((packageInfo) => packageInfo.name),
'ktx-sl',
'ktx-daemon',
'kaelio-ktx',
],
packageNames: ['@kaelio/ktx', 'ktx-sl', 'ktx-daemon', 'kaelio-ktx'],
publishedPackageSmokeGate: {
status: 'required',
script: 'pnpm run release:published-smoke',
reason: 'Run the published package smoke before accepting the hybrid-search release.',
configSource: 'release-policy',
packageName: '@ktx/cli-public',
packageName: '@kaelio/ktx',
version: '2026.5.8',
registry: 'https://registry.npmjs.org/',
},
@ -244,6 +230,11 @@ describe('release readiness policy', () => {
await writeReadyFixture(root, {
policy: releasePolicy({
releaseMode: 'published-package-smoke-required',
publishedPackageSmoke: {
packageName: null,
version: 'latest',
registry: null,
},
requiredBeforePublishing: [],
}),
});
@ -264,7 +255,7 @@ describe('release readiness policy', () => {
policy: releasePolicy({
releaseMode: 'published-package-smoke-required',
publishedPackageSmoke: {
packageName: '@ktx/cli-public',
packageName: '@kaelio/ktx',
version: 'latest',
registry: null,
},
@ -356,14 +347,20 @@ describe('release readiness policy', () => {
}
});
it('rejects a public npm package while releaseMode is ci-artifact-only', async () => {
const root = await mkdtemp(join(tmpdir(), 'ktx-release-public-npm-test-'));
it('rejects release policy that still lists internal npm packages', async () => {
const root = await mkdtemp(join(tmpdir(), 'ktx-release-stale-internal-npm-policy-test-'));
try {
await writeReadyFixture(root, { contextPrivate: false });
await writeReadyFixture(root, {
policy: releasePolicy({
npm: {
packages: ['@kaelio/ktx', '@ktx/context'],
},
}),
});
await assert.rejects(
() => releaseReadinessReport(root),
/ci-artifact-only policy npm package @ktx\/context must remain private/,
/Release policy npm\.packages mismatch/,
);
} finally {
await rm(root, { recursive: true, force: true });