fix: derive runtime versions from release metadata

This commit is contained in:
Andrey Avtomonov 2026-05-17 19:00:34 +02:00
parent 1c30abc51d
commit 8aea27bfbe
18 changed files with 231 additions and 50 deletions

View file

@ -100,6 +100,12 @@ The artifact packaging and readiness scripts read `publicNpmPackageVersion`
from `release-policy.json`, so manual version edits in build scripts aren't
needed for rc releases.
The bundled Python runtime wheel also derives its version from
`publicNpmPackageVersion`. Stable npm versions are reused as-is, and rc
versions are normalized to Python's version format. For example,
`0.1.0-rc.2` becomes `0.1.0rc2` in the `kaelio-ktx` wheel filename and wheel
metadata.
## npm authentication
The release workflow publishes through npm Trusted Publishing. It doesn't use

View file

@ -11,7 +11,13 @@ function stubIo(): KtxCliIo {
}
function stubPackageInfo(): KtxCliPackageInfo {
return { name: '@ktx/cli', version: '0.0.0-test', contextPackageName: '@ktx/context' };
return {
name: '@ktx/cli',
version: '0.0.0-test',
packageVersion: '0.0.0-private',
runtimeVersion: '0.0.0-test',
contextPackageName: '@ktx/context',
};
}
describe('buildKtxProgram', () => {

View file

@ -10,6 +10,7 @@ import type { KtxSlArgs } from './sl.js';
import type { KtxSqlArgs } from './sql.js';
import { profileMark, profileSpan } from './startup-profile.js';
import type { KtxTextIngestArgs } from './text-ingest.js';
import { resolveKtxRuntimeVersion } from './release-version.js';
profileMark('module:cli-runtime');
@ -18,6 +19,8 @@ const requirePackageJson = createRequire(import.meta.url);
export interface KtxCliPackageInfo {
name: string;
version: string;
packageVersion: string;
runtimeVersion: string;
contextPackageName: '@ktx/context';
}
@ -61,9 +64,16 @@ export function packageInfoFromJson(packageJson: unknown): KtxCliPackageInfo {
throw new Error('Invalid KTX CLI package metadata');
}
const runtimeVersion = resolveKtxRuntimeVersion({
packageName: packageJson.name,
packageVersion: packageJson.version,
});
return {
name: packageJson.name,
version: packageJson.version,
version: runtimeVersion,
packageVersion: packageJson.version,
runtimeVersion,
contextPackageName: '@ktx/context',
};
}

View file

@ -45,7 +45,9 @@ describe('getKtxCliPackageInfo', () => {
it('identifies the CLI package and its context dependency', () => {
expect(getKtxCliPackageInfo()).toEqual({
name: '@ktx/cli',
version: '0.0.0-private',
version: '0.1.0-rc.1',
packageVersion: '0.0.0-private',
runtimeVersion: '0.1.0-rc.1',
contextPackageName: '@ktx/context',
});
});
@ -68,6 +70,8 @@ describe('getKtxCliPackageInfo', () => {
).toEqual({
name: '@kaelio/ktx',
version: '0.1.0',
packageVersion: '0.1.0',
runtimeVersion: '0.1.0',
contextPackageName: '@ktx/context',
});
});
@ -114,7 +118,7 @@ describe('runKtxCli', () => {
await expect(runKtxCli(['--version'], testIo.io)).resolves.toBe(0);
expect(testIo.stdout()).toBe('@ktx/cli 0.0.0-private\n');
expect(testIo.stdout()).toBe('@ktx/cli 0.1.0-rc.1\n');
expect(testIo.stderr()).toBe('');
});
@ -252,7 +256,7 @@ describe('runKtxCli', () => {
expect(listIo.stderr()).toContain("unknown option '--query'");
});
it('routes runtime management commands with the CLI package version', async () => {
it('routes runtime management commands with the release runtime version', async () => {
const runtime = vi.fn(async () => 0);
const installIo = makeIo();
const startIo = makeIo();
@ -278,7 +282,7 @@ describe('runKtxCli', () => {
1,
{
command: 'install',
cliVersion: '0.0.0-private',
cliVersion: '0.1.0-rc.1',
feature: 'local-embeddings',
force: true,
},
@ -288,7 +292,7 @@ describe('runKtxCli', () => {
2,
{
command: 'start',
cliVersion: '0.0.0-private',
cliVersion: '0.1.0-rc.1',
projectDir: expect.any(String),
feature: 'local-embeddings',
force: true,
@ -299,7 +303,7 @@ describe('runKtxCli', () => {
3,
{
command: 'stop',
cliVersion: '0.0.0-private',
cliVersion: '0.1.0-rc.1',
projectDir: expect.any(String),
all: false,
},
@ -309,7 +313,7 @@ describe('runKtxCli', () => {
4,
{
command: 'stop',
cliVersion: '0.0.0-private',
cliVersion: '0.1.0-rc.1',
projectDir: expect.any(String),
all: true,
},
@ -319,7 +323,7 @@ describe('runKtxCli', () => {
5,
{
command: 'status',
cliVersion: '0.0.0-private',
cliVersion: '0.1.0-rc.1',
json: true,
},
statusIo.io,
@ -392,7 +396,7 @@ describe('runKtxCli', () => {
expect.objectContaining({
command: 'query',
projectDir: tempDir,
cliVersion: '0.0.0-private',
cliVersion: '0.1.0-rc.1',
runtimeInstallPolicy: 'prompt',
query: expect.objectContaining({ measures: ['orders.order_count'], dimensions: [] }),
}),
@ -407,7 +411,7 @@ describe('runKtxCli', () => {
).resolves.toBe(0);
expect(sl).toHaveBeenLastCalledWith(
expect.objectContaining({
cliVersion: '0.0.0-private',
cliVersion: '0.1.0-rc.1',
runtimeInstallPolicy: 'auto',
}),
autoIo.io,
@ -423,7 +427,7 @@ describe('runKtxCli', () => {
).resolves.toBe(0);
expect(sl).toHaveBeenLastCalledWith(
expect.objectContaining({
cliVersion: '0.0.0-private',
cliVersion: '0.1.0-rc.1',
runtimeInstallPolicy: 'never',
}),
noInputIo.io,
@ -562,7 +566,7 @@ describe('runKtxCli', () => {
skipAgents: false,
inputMode: 'auto',
yes: false,
cliVersion: '0.0.0-private',
cliVersion: '0.1.0-rc.1',
skipLlm: false,
skipEmbeddings: false,
databaseSchemas: [],
@ -692,7 +696,7 @@ describe('runKtxCli', () => {
inputMode: 'disabled',
depth: 'fast',
queryHistory: 'default',
cliVersion: '0.0.0-private',
cliVersion: '0.1.0-rc.1',
runtimeInstallPolicy: 'never',
},
testIo.io,
@ -719,7 +723,7 @@ describe('runKtxCli', () => {
inputMode: 'auto',
depth: 'deep',
queryHistory: 'default',
cliVersion: '0.0.0-private',
cliVersion: '0.1.0-rc.1',
runtimeInstallPolicy: 'prompt',
},
testIo.io,
@ -796,7 +800,7 @@ describe('runKtxCli', () => {
json: false,
inputMode: 'disabled',
queryHistory: 'default',
cliVersion: '0.0.0-private',
cliVersion: '0.1.0-rc.1',
runtimeInstallPolicy: 'never',
},
testIo.io,
@ -1074,7 +1078,7 @@ describe('runKtxCli', () => {
command: 'run',
projectDir: tempDir,
inputMode: 'disabled',
cliVersion: '0.0.0-private',
cliVersion: '0.1.0-rc.1',
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
anthropicModel: 'claude-sonnet-4-6',
skipLlm: false,
@ -1113,7 +1117,7 @@ describe('runKtxCli', () => {
command: 'run',
projectDir: tempDir,
inputMode: 'disabled',
cliVersion: '0.0.0-private',
cliVersion: '0.1.0-rc.1',
llmBackend: 'vertex',
vertexProject: 'local-gcp-project',
vertexLocation: 'us-east5',
@ -1150,7 +1154,7 @@ describe('runKtxCli', () => {
command: 'run',
projectDir: tempDir,
inputMode: 'disabled',
cliVersion: '0.0.0-private',
cliVersion: '0.1.0-rc.1',
llmBackend: 'claude-code',
llmModel: 'opus',
skipLlm: false,
@ -1258,7 +1262,7 @@ describe('runKtxCli', () => {
projectDir: '/tmp/project',
inputMode: 'disabled',
yes: true,
cliVersion: '0.0.0-private',
cliVersion: '0.1.0-rc.1',
skipLlm: true,
skipEmbeddings: true,
databaseDrivers: ['postgres'],
@ -1576,7 +1580,7 @@ describe('runKtxCli', () => {
queryFile: '/tmp/query.json',
execute: false,
format: 'json',
cliVersion: '0.0.0-private',
cliVersion: '0.1.0-rc.1',
runtimeInstallPolicy: 'auto',
},
autoIo.io,
@ -1590,7 +1594,7 @@ describe('runKtxCli', () => {
queryFile: '/tmp/query.json',
execute: false,
format: 'json',
cliVersion: '0.0.0-private',
cliVersion: '0.1.0-rc.1',
runtimeInstallPolicy: 'never',
},
neverIo.io,

View file

@ -11,7 +11,13 @@ function silentIo(): KtxCliIo {
}
function stubPackageInfo(): KtxCliPackageInfo {
return { name: '@ktx/cli', version: '0.0.0-docs', contextPackageName: '@ktx/context' };
return {
name: '@ktx/cli',
version: '0.0.0-docs',
packageVersion: '0.0.0-private',
runtimeVersion: '0.0.0-docs',
contextPackageName: '@ktx/context',
};
}
export function renderKtxCommandTree(): string {

View file

@ -0,0 +1,55 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, parse } from 'node:path';
import { fileURLToPath } from 'node:url';
const semverPattern =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function assertReleaseVersion(value: unknown, source: string): string {
if (typeof value !== 'string' || !semverPattern.test(value)) {
throw new Error(`Invalid KTX release version in ${source}`);
}
return value;
}
function findReleasePolicyPath(startDir: string): string | undefined {
let current = startDir;
const root = parse(current).root;
while (true) {
const candidate = join(current, 'release-policy.json');
if (existsSync(candidate)) {
return candidate;
}
if (current === root) {
return undefined;
}
current = dirname(current);
}
}
function readSourceReleaseVersion(startDir = dirname(fileURLToPath(import.meta.url))): string | undefined {
const policyPath = findReleasePolicyPath(startDir);
if (!policyPath) {
return undefined;
}
const policy = JSON.parse(readFileSync(policyPath, 'utf8')) as unknown;
if (!isPlainObject(policy)) {
throw new Error(`Invalid KTX release policy: ${policyPath}`);
}
return assertReleaseVersion(policy.publicNpmPackageVersion, policyPath);
}
export function resolveKtxRuntimeVersion(input: {
packageName: string;
packageVersion: string;
startDir?: string;
}): string {
if (input.packageName === '@kaelio/ktx') {
return assertReleaseVersion(input.packageVersion, `${input.packageName}/package.json`);
}
return readSourceReleaseVersion(input.startDir) ?? input.packageVersion;
}

View file

@ -1,6 +1,6 @@
[project]
name = "ktx-daemon"
version = "0.1.0"
version = "0.0.0+private"
description = "Portable compute package for KTX semantic-layer operations"
readme = "README.md"
requires-python = ">=3.13"

View file

@ -1,6 +1,28 @@
"""Portable compute package for KTX."""
PACKAGE_NAME = "ktx-daemon"
VERSION = "0.1.0"
from collections.abc import Callable
from importlib.metadata import PackageNotFoundError, version
__all__ = ["PACKAGE_NAME", "VERSION"]
PACKAGE_NAME = "ktx-daemon"
RUNTIME_DISTRIBUTION_NAME = "kaelio-ktx"
def resolve_package_version(
version_loader: Callable[[str], str] = version,
) -> str:
for distribution_name in (RUNTIME_DISTRIBUTION_NAME, PACKAGE_NAME):
try:
return version_loader(distribution_name)
except PackageNotFoundError:
continue
return "0.0.0+local"
VERSION = resolve_package_version()
__all__ = [
"PACKAGE_NAME",
"RUNTIME_DISTRIBUTION_NAME",
"VERSION",
"resolve_package_version",
]

View file

@ -10,6 +10,7 @@ from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
from ktx_daemon import VERSION
from ktx_daemon.code_execution import (
ExecuteCodeRequest,
ExecuteCodeResponse,
@ -84,7 +85,7 @@ def create_app(
app = FastAPI(
title="KTX Daemon",
description="Stateless portable compute server for KTX.",
version="0.1.0",
version=VERSION,
)
@app.get("/health")

View file

@ -1,6 +1,19 @@
from ktx_daemon import PACKAGE_NAME, VERSION
from ktx_daemon import PACKAGE_NAME, VERSION, resolve_package_version
def test_package_metadata() -> None:
assert PACKAGE_NAME == "ktx-daemon"
assert VERSION == "0.1.0"
assert VERSION == resolve_package_version()
def test_package_version_prefers_bundled_runtime_distribution() -> None:
calls: list[str] = []
def fake_version(distribution_name: str) -> str:
calls.append(distribution_name)
if distribution_name == "kaelio-ktx":
return "0.1.0rc1"
raise AssertionError(f"unexpected distribution lookup: {distribution_name}")
assert resolve_package_version(version_loader=fake_version) == "0.1.0rc1"
assert calls == ["kaelio-ktx"]

View file

@ -1,6 +1,6 @@
[project]
name = "ktx-sl"
version = "0.1.0"
version = "0.0.0+private"
description = "Agent-first semantic layer engine with aggregate locality"
readme = "README.md"
requires-python = ">=3.13"

View file

@ -6,11 +6,13 @@ import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { promisify } from 'node:util';
import { publicPythonRuntimePackageVersion } from './public-npm-release-metadata.mjs';
const execFileAsync = promisify(execFile);
export const RUNTIME_WHEEL_DISTRIBUTION_NAME = 'kaelio-ktx';
export const RUNTIME_WHEEL_NORMALIZED_NAME = 'kaelio_ktx';
export const RUNTIME_WHEEL_PACKAGE_VERSION = '0.1.0';
export const RUNTIME_WHEEL_PACKAGE_VERSION = publicPythonRuntimePackageVersion();
function scriptRootDir() {
return resolve(dirname(fileURLToPath(import.meta.url)), '..');

View file

@ -48,11 +48,11 @@ describe('runtimeWheelLayout', () => {
});
describe('runtimeWheelPyproject', () => {
it('describes one kaelio-ktx wheel with lazy local embeddings', () => {
it('describes one kaelio-ktx wheel with the release-derived Python version and lazy local embeddings', () => {
const pyproject = runtimeWheelPyproject();
assert.match(pyproject, /name = "kaelio-ktx"/);
assert.match(pyproject, /version = "0\.1\.0"/);
assert.match(pyproject, /version = "0\.1\.0rc1"/);
assert.match(pyproject, /ktx-daemon = "ktx_daemon\.__main__:main"/);
assert.match(pyproject, /packages = \["semantic_layer", "ktx_daemon"\]/);
assert.match(pyproject, /\[project\.optional-dependencies\]/);
@ -110,6 +110,6 @@ describe('runtimeWheelBuildCommand', () => {
cwd: '/repo/ktx',
});
assert.equal(RUNTIME_WHEEL_DISTRIBUTION_NAME, 'kaelio-ktx');
assert.equal(RUNTIME_WHEEL_PACKAGE_VERSION, '0.1.0');
assert.equal(RUNTIME_WHEEL_PACKAGE_VERSION, '0.1.0rc1');
});
});

View file

@ -82,7 +82,7 @@ async function writeUploadableArtifactFixtures(layout) {
`${packageInfo.name}-tarball`,
]),
[
join(layout.pythonDir, 'kaelio_ktx-0.1.0-py3-none-any.whl'),
join(layout.pythonDir, 'kaelio_ktx-0.1.0rc1-py3-none-any.whl'),
'kaelio-ktx-runtime-wheel',
],
]);
@ -139,7 +139,7 @@ describe('packageReleaseMetadata', () => {
ecosystem: 'python',
packageName: 'kaelio-ktx',
packageRoot: 'python/runtime-wheel',
packageVersion: '0.1.0',
packageVersion: '0.1.0rc1',
private: false,
releaseMode: 'ci-artifact-only',
},
@ -154,10 +154,10 @@ describe('findPythonArtifacts', () => {
it('finds the bundled runtime wheel only', async () => {
const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-test-'));
try {
await writeFile(join(root, 'kaelio_ktx-0.1.0-py3-none-any.whl'), '');
await writeFile(join(root, 'kaelio_ktx-0.1.0rc1-py3-none-any.whl'), '');
assert.deepEqual(await findPythonArtifacts(root), {
runtimeWheel: join(root, 'kaelio_ktx-0.1.0-py3-none-any.whl'),
runtimeWheel: join(root, 'kaelio_ktx-0.1.0rc1-py3-none-any.whl'),
});
} finally {
await rm(root, { recursive: true, force: true });
@ -210,7 +210,7 @@ describe('artifact manifest', () => {
ecosystem: 'python',
packageName: 'kaelio-ktx',
packageRoot: 'python/runtime-wheel',
packageVersion: '0.1.0',
packageVersion: '0.1.0rc1',
private: false,
releaseMode: 'ci-artifact-only',
},
@ -252,8 +252,8 @@ describe('artifact manifest', () => {
artifactKind: 'wheel',
ecosystem: 'python',
packageName: 'kaelio-ktx',
packageVersion: '0.1.0',
path: 'python/kaelio_ktx-0.1.0-py3-none-any.whl',
packageVersion: '0.1.0rc1',
path: 'python/kaelio_ktx-0.1.0rc1-py3-none-any.whl',
},
],
);
@ -362,17 +362,17 @@ describe('copyRuntimeWheelAssets', () => {
try {
await mkdir(layout.pythonDir, { recursive: true });
await writeFile(
join(layout.pythonDir, 'kaelio_ktx-0.1.0-py3-none-any.whl'),
join(layout.pythonDir, 'kaelio_ktx-0.1.0rc1-py3-none-any.whl'),
'kaelio-ktx-runtime-wheel',
);
const assets = await copyRuntimeWheelAssets(layout, {
runtimeWheel: join(layout.pythonDir, 'kaelio_ktx-0.1.0-py3-none-any.whl'),
runtimeWheel: join(layout.pythonDir, 'kaelio_ktx-0.1.0rc1-py3-none-any.whl'),
});
assert.equal(
assets.wheelPath,
join(root, 'packages', 'cli', 'assets', 'python', 'kaelio_ktx-0.1.0-py3-none-any.whl'),
join(root, 'packages', 'cli', 'assets', 'python', 'kaelio_ktx-0.1.0rc1-py3-none-any.whl'),
);
assert.equal(
assets.manifestPath,
@ -385,7 +385,7 @@ describe('copyRuntimeWheelAssets', () => {
normalizedName: RUNTIME_WHEEL_NORMALIZED_NAME,
version: RUNTIME_WHEEL_PACKAGE_VERSION,
wheel: {
file: 'kaelio_ktx-0.1.0-py3-none-any.whl',
file: 'kaelio_ktx-0.1.0rc1-py3-none-any.whl',
sha256: createHash('sha256')
.update('kaelio-ktx-runtime-wheel')
.digest('hex'),

View file

@ -9,6 +9,8 @@ export const PUBLIC_NPM_RELEASE_TAGS = new Set(['latest', 'next']);
const SEMVER_PATTERN =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
const SEMVER_PARTS_PATTERN =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
function scriptRootDir() {
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
@ -29,6 +31,30 @@ export function assertPublicNpmPackageVersion(version) {
return version;
}
export function publicNpmPackageVersionToPythonVersion(version) {
const safeVersion = assertPublicNpmPackageVersion(version);
const match = SEMVER_PARTS_PATTERN.exec(safeVersion);
if (!match) {
throw new Error(`Invalid public npm package version: ${version}`);
}
const [, major, minor, patch, prerelease, buildMetadata] = match;
if (buildMetadata) {
throw new Error(`Unsupported public npm build metadata for Python runtime version: ${safeVersion}`);
}
const baseVersion = `${major}.${minor}.${patch}`;
if (!prerelease) {
return baseVersion;
}
const rcMatch = /^rc\.([1-9]\d*|0)$/.exec(prerelease);
if (!rcMatch) {
throw new Error(`Unsupported public npm prerelease for Python runtime version: ${safeVersion}`);
}
return `${baseVersion}rc${rcMatch[1]}`;
}
export function assertPublicNpmReleaseTag(tag) {
if (!PUBLIC_NPM_RELEASE_TAGS.has(tag)) {
throw new Error(`Invalid public npm release tag: ${tag}`);
@ -51,3 +77,7 @@ export function readPublicNpmReleaseMetadata(rootDir = scriptRootDir()) {
export function publicNpmPackageVersion(rootDir = scriptRootDir()) {
return readPublicNpmReleaseMetadata(rootDir).version;
}
export function publicPythonRuntimePackageVersion(rootDir = scriptRootDir()) {
return publicNpmPackageVersionToPythonVersion(publicNpmPackageVersion(rootDir));
}

View file

@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { publicNpmPackageVersionToPythonVersion } from './public-npm-release-metadata.mjs';
describe('publicNpmPackageVersionToPythonVersion', () => {
it('keeps stable public npm versions unchanged for Python wheels', () => {
assert.equal(publicNpmPackageVersionToPythonVersion('1.2.3'), '1.2.3');
});
it('converts semantic-release rc versions to PEP 440 rc versions', () => {
assert.equal(publicNpmPackageVersionToPythonVersion('0.1.0-rc.1'), '0.1.0rc1');
assert.equal(publicNpmPackageVersionToPythonVersion('2.0.0-rc.12'), '2.0.0rc12');
});
it('rejects unsupported prerelease and build metadata forms', () => {
assert.throws(
() => publicNpmPackageVersionToPythonVersion('1.2.3-beta.1'),
/Unsupported public npm prerelease for Python runtime version/,
);
assert.throws(
() => publicNpmPackageVersionToPythonVersion('1.2.3+build.1'),
/Unsupported public npm build metadata for Python runtime version/,
);
});
});

View file

@ -37,7 +37,7 @@ async function writeUploadableArtifactFixtures(layout) {
layout.npmTarballs[packageInfo.name],
`${packageInfo.name}-tarball`,
]),
[join(layout.pythonDir, 'kaelio_ktx-0.1.0-py3-none-any.whl'), 'kaelio-ktx-runtime-wheel'],
[join(layout.pythonDir, 'kaelio_ktx-0.1.0rc1-py3-none-any.whl'), 'kaelio-ktx-runtime-wheel'],
]);
for (const [path, contents] of fileContents) {

4
uv.lock generated
View file

@ -440,7 +440,7 @@ wheels = [
[[package]]
name = "ktx-daemon"
version = "0.1.0"
version = "0.0.0+private"
source = { editable = "python/ktx-daemon" }
dependencies = [
{ name = "fastapi" },
@ -495,7 +495,7 @@ dev = [
[[package]]
name = "ktx-sl"
version = "0.1.0"
version = "0.0.0+private"
source = { editable = "python/ktx-sl" }
dependencies = [
{ name = "pydantic" },