refactor(release): single source of truth for package version

Make packages/cli/package.json the single source of truth for the
@kaelio/ktx version. publicNpmPackageVersion() now reads it directly,
so artifact filenames, release-readiness checks, and the Python wheel
version all derive from one field. The duplicate
release-policy.json.publicNpmPackageVersion is removed.

Previously the two fields could drift: tarballs were named
kaelio-ktx-0.4.1.tgz while internally containing
@kaelio/ktx@0.0.0-private.

- update-public-release-version.mjs rewrites both Python pyproject.toml
  files (ktx-daemon, ktx-sl) alongside the npm package.jsons,
  normalizing the version for PEP 440 (e.g. 0.1.0-rc.2 -> 0.1.0rc2).
- semantic-release-config.cjs adds the two pyproject.toml files to
  @semantic-release/git assets so the release commit back to main
  carries every version source in lockstep.
- The six "?? '0.0.0-private'" fallback literals across the CLI are
  replaced with "?? getKtxCliPackageInfo().version", and
  createDefaultKtxMcpServer makes its version arg required.
- docs/release.md describes the actual commit-back model: the dev tree
  always reflects the most recent release; no sentinel pin to
  maintain.

Verified: pnpm run artifacts:build now produces
kaelio-ktx-0.4.1.tgz and kaelio_ktx-0.4.1-py3-none-any.whl with
@kaelio/ktx@0.4.1 inside. Full type-check, dead-code, and
2287 vitests + 173 script tests pass.
This commit is contained in:
Andrey Avtomonov 2026-05-21 14:23:28 +02:00
parent 36805c4533
commit 99f7a31919
22 changed files with 168 additions and 54 deletions

View file

@ -52,7 +52,10 @@ describe('runtimeWheelPyproject', () => {
const pyproject = runtimeWheelPyproject();
assert.match(pyproject, /name = "kaelio-ktx"/);
assert.match(pyproject, new RegExp(`version = "${RUNTIME_WHEEL_PACKAGE_VERSION.replace(/\./g, '\\.')}"`));
assert.match(
pyproject,
new RegExp(`version = "${RUNTIME_WHEEL_PACKAGE_VERSION.replace(/[.+]/g, (char) => `\\${char}`)}"`),
);
assert.match(pyproject, /ktx-daemon = "ktx_daemon\.__main__:main"/);
assert.match(pyproject, /packages = \["semantic_layer", "ktx_daemon"\]/);
assert.match(pyproject, /\[project\.optional-dependencies\]/);

View file

@ -57,11 +57,11 @@ describe('publicKtxTarballName', () => {
});
describe('expectedPublicKtxVersionPattern', () => {
it('matches the public package version and rejects the private workspace version', () => {
it('matches the public package version and rejects other versions', () => {
const pattern = expectedPublicKtxVersionPattern();
assert.match(`@kaelio/ktx ${PUBLIC_NPM_PACKAGE_VERSION}\n`, pattern);
assert.doesNotMatch('@kaelio/ktx 0.0.0-private\n', pattern);
assert.doesNotMatch('@kaelio/ktx 9.9.9-other\n', pattern);
});
});

View file

@ -34,7 +34,6 @@ async function writeJson(path, value) {
async function writeReleaseMetadataInputs(root) {
await writeJson(join(root, 'release-policy.json'), {
schemaVersion: 1,
publicNpmPackageVersion: PUBLIC_NPM_PACKAGE_VERSION,
releaseMode: 'ci-artifact-only',
npm: {
publish: false,

View file

@ -25,6 +25,10 @@ export function releasePolicyPath(rootDir = scriptRootDir()) {
return join(rootDir, 'release-policy.json');
}
export function cliPackageJsonPath(rootDir = scriptRootDir()) {
return join(rootDir, 'packages', 'cli', 'package.json');
}
function readJsonSync(path) {
return JSON.parse(readFileSync(path, 'utf8'));
}
@ -70,20 +74,24 @@ export function assertPublicNpmReleaseTag(tag) {
throw new Error(`Invalid public npm release tag: ${tag}`);
}
function readCliPackageVersion(rootDir = scriptRootDir()) {
const packageJson = readJsonSync(cliPackageJsonPath(rootDir));
return assertPublicNpmPackageVersion(packageJson.version);
}
export function readPublicNpmReleaseMetadata(rootDir = scriptRootDir()) {
const policy = readJsonSync(releasePolicyPath(rootDir));
const version = assertPublicNpmPackageVersion(policy.publicNpmPackageVersion);
const tag = assertPublicNpmReleaseTag(policy.npm?.tag);
return {
packageName: PUBLIC_NPM_PACKAGE_NAME,
version,
version: readCliPackageVersion(rootDir),
tag,
};
}
export function publicNpmPackageVersion(rootDir = scriptRootDir()) {
return readPublicNpmReleaseMetadata(rootDir).version;
return readCliPackageVersion(rootDir);
}
export const PUBLIC_NPM_PACKAGE_VERSION = publicNpmPackageVersion();

View file

@ -5,7 +5,7 @@ import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { packageArtifactLayout, packageReleaseMetadata, verifyArtifactManifest } from './package-artifacts.mjs';
import { assertPublicNpmPackageVersion, publicNpmPackageVersion } from './public-npm-release-metadata.mjs';
import { publicNpmPackageVersion } from './public-npm-release-metadata.mjs';
import { readPublishedPackageSmokeConfig } from './published-package-smoke-config.mjs';
function scriptRootDir() {
@ -138,8 +138,6 @@ export function validateReleasePolicy(policy) {
throw new Error(`Unsupported release policy schemaVersion: ${policy.schemaVersion}`);
}
assertSupportedReleaseMode(policy.releaseMode);
assertString(policy.publicNpmPackageVersion, 'Release policy publicNpmPackageVersion');
assertPublicNpmPackageVersion(policy.publicNpmPackageVersion);
assertPlainObject(policy.npm, 'Release policy npm');
assertPlainObject(policy.python, 'Release policy python');
assertPlainObject(policy.publishedPackageSmoke, 'Release policy publishedPackageSmoke');

View file

@ -52,7 +52,6 @@ function releasePolicy(overrides = {}) {
return {
schemaVersion: 1,
publicNpmPackageVersion: PUBLIC_NPM_PACKAGE_VERSION,
releaseMode: 'ci-artifact-only',
npm: {
publish: false,

View file

@ -166,7 +166,13 @@ function createReleaseConfig(env = process.env) {
[
'@semantic-release/git',
{
assets: ['package.json', 'release-policy.json', 'packages/cli/package.json'],
assets: [
'package.json',
'release-policy.json',
'packages/cli/package.json',
'python/ktx-daemon/pyproject.toml',
'python/ktx-sl/pyproject.toml',
],
message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
},
],

View file

@ -88,7 +88,13 @@ describe('semantic-release config', () => {
const config = createReleaseConfig({ KTX_RELEASE_KIND: kind, GITHUB_REF_NAME: 'main' });
const git = gitPluginOptions(config);
assert.ok(git, `${kind}: @semantic-release/git plugin must be configured`);
assert.deepEqual(git.assets, ['package.json', 'release-policy.json', 'packages/cli/package.json']);
assert.deepEqual(git.assets, [
'package.json',
'release-policy.json',
'packages/cli/package.json',
'python/ktx-daemon/pyproject.toml',
'python/ktx-sl/pyproject.toml',
]);
assert.match(git.message, /^chore\(release\): \$\{nextRelease\.version\} \[skip ci\]/);
}
});

View file

@ -8,6 +8,7 @@ import {
PUBLIC_NPM_PACKAGE_NAME,
assertPublicNpmPackageVersion,
assertPublicNpmReleaseTag,
publicNpmPackageVersionToPythonVersion,
releasePolicyPath,
} from './public-npm-release-metadata.mjs';
@ -23,6 +24,42 @@ async function writeJson(path, value) {
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
}
function pyprojectWithProjectVersion(source, version) {
const lines = source.split('\n');
let inProject = false;
let replaced = false;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const sectionMatch = /^\s*\[([^\]]+)\]\s*$/.exec(line);
if (sectionMatch) {
inProject = sectionMatch[1] === 'project';
continue;
}
if (inProject && /^\s*version\s*=\s*"[^"]*"\s*$/.test(line)) {
lines[index] = `version = "${version}"`;
replaced = true;
break;
}
}
if (!replaced) {
throw new Error('No [project].version assignment found in pyproject.toml');
}
return lines.join('\n');
}
async function rewritePyprojectVersion(path, version) {
const source = await readFile(path, 'utf8');
await writeFile(path, pyprojectWithProjectVersion(source, version));
}
function safePythonVersionFor(version) {
try {
return publicNpmPackageVersionToPythonVersion(version);
} catch {
return null;
}
}
export async function updatePublicReleaseVersion(rootDir, version, tag) {
const safeVersion = assertPublicNpmPackageVersion(version);
const safeTag = assertPublicNpmReleaseTag(tag);
@ -37,9 +74,15 @@ export async function updatePublicReleaseVersion(rootDir, version, tag) {
cliPackageJson.version = safeVersion;
await writeJson(cliPackageJsonPath, cliPackageJson);
const pythonVersion = safePythonVersionFor(safeVersion);
if (pythonVersion !== null) {
await rewritePyprojectVersion(join(rootDir, 'python', 'ktx-daemon', 'pyproject.toml'), pythonVersion);
await rewritePyprojectVersion(join(rootDir, 'python', 'ktx-sl', 'pyproject.toml'), pythonVersion);
}
const policyPath = releasePolicyPath(rootDir);
const policy = await readJson(policyPath);
policy.publicNpmPackageVersion = safeVersion;
delete policy.publicNpmPackageVersion;
policy.releaseMode = 'npm-public-release-ready';
policy.requiredBeforePublishing = [];
policy.npm = {
@ -60,6 +103,7 @@ export async function updatePublicReleaseVersion(rootDir, version, tag) {
return {
version: safeVersion,
tag: safeTag,
pythonVersion,
};
}

View file

@ -11,23 +11,51 @@ async function writeJson(path, value) {
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
}
async function writeText(path, value) {
await mkdir(join(path, '..'), { recursive: true });
await writeFile(path, value);
}
async function readJson(path) {
return JSON.parse(await readFile(path, 'utf8'));
}
async function readText(path) {
return readFile(path, 'utf8');
}
const DAEMON_PYPROJECT = `[project]
name = "ktx-daemon"
version = "0.4.0"
description = "Portable compute package for KTX semantic-layer operations"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
`;
const SL_PYPROJECT = `[project]
name = "ktx-sl"
version = "0.4.0"
description = "Agent-first semantic layer engine with aggregate locality"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
`;
async function writeReleaseFixture(root) {
await writeJson(join(root, 'package.json'), {
name: 'ktx-workspace',
version: '0.0.0-private',
version: '0.4.0',
private: true,
});
await writeJson(join(root, 'packages', 'cli', 'package.json'), {
name: '@kaelio/ktx',
version: '0.0.0-private',
version: '0.4.0',
});
await writeJson(join(root, 'release-policy.json'), {
schemaVersion: 1,
publicNpmPackageVersion: '0.1.0-rc.1',
releaseMode: 'ci-artifact-only',
npm: {
publish: false,
@ -43,7 +71,7 @@ async function writeReleaseFixture(root) {
},
publishedPackageSmoke: {
packageName: '@kaelio/ktx',
version: '0.1.0-rc.1',
version: 'latest',
registry: null,
},
runtimeInstaller: {
@ -53,6 +81,8 @@ async function writeReleaseFixture(root) {
},
requiredBeforePublishing: ['Choose public release version.'],
});
await writeText(join(root, 'python', 'ktx-daemon', 'pyproject.toml'), DAEMON_PYPROJECT);
await writeText(join(root, 'python', 'ktx-sl', 'pyproject.toml'), SL_PYPROJECT);
}
describe('updatePublicReleaseVersion', () => {
@ -65,9 +95,10 @@ describe('updatePublicReleaseVersion', () => {
assert.equal((await readJson(join(root, 'package.json'))).version, '0.1.0-rc.2');
assert.equal((await readJson(join(root, 'packages', 'cli', 'package.json'))).version, '0.1.0-rc.2');
assert.match(await readText(join(root, 'python', 'ktx-daemon', 'pyproject.toml')), /^version = "0\.1\.0rc2"$/m);
assert.match(await readText(join(root, 'python', 'ktx-sl', 'pyproject.toml')), /^version = "0\.1\.0rc2"$/m);
assert.deepEqual(await readJson(join(root, 'release-policy.json')), {
schemaVersion: 1,
publicNpmPackageVersion: '0.1.0-rc.2',
releaseMode: 'npm-public-release-ready',
npm: {
publish: true,
@ -110,8 +141,17 @@ describe('updatePublicReleaseVersion', () => {
(await readJson(join(root, 'packages', 'cli', 'package.json'))).version,
'0.1.0-feature-foo.0',
);
assert.match(
await readText(join(root, 'python', 'ktx-daemon', 'pyproject.toml')),
/^version = "0\.4\.0"$/m,
);
assert.match(
await readText(join(root, 'python', 'ktx-sl', 'pyproject.toml')),
/^version = "0\.4\.0"$/m,
);
const policy = await readJson(join(root, 'release-policy.json'));
assert.equal(policy.publicNpmPackageVersion, '0.1.0-feature-foo.0');
assert.equal(policy.publicNpmPackageVersion, undefined);
assert.equal(policy.publishedPackageSmoke.version, '0.1.0-feature-foo.0');
assert.equal(policy.npm.tag, 'branch-feature-foo');
} finally {
await rm(root, { recursive: true, force: true });