ci: simplify ktx release flow

This commit is contained in:
Andrey Avtomonov 2026-05-19 16:33:41 +02:00
parent 7110aa6f5c
commit bbd9568287
14 changed files with 98 additions and 510 deletions

View file

@ -10,7 +10,7 @@ const identifierSkipPrefixes = ['docs/', 'docs-site/', 'examples/', 'python/ktx-
const identifierAllowPatterns = [
/^packages\/cli\/src\/(?:index|managed-local-embeddings|managed-python-command|managed-python-daemon|managed-python-runtime|release-version|runtime)(?:\.test)?\.ts$/,
/^python\/ktx-daemon\/src\/ktx_daemon\/__init__\.py$/,
/^scripts\/(?:build-public-npm-package|build-python-runtime-wheel|local-embeddings-runtime-smoke|package-artifacts|public-npm-release-metadata|publish-public-npm-package|published-package-smoke|release-readiness)(?:\.test)?\.mjs$/,
/^scripts\/(?:build-public-npm-package|build-python-runtime-wheel|local-embeddings-runtime-smoke|package-artifacts|public-npm-release-metadata|published-package-smoke|release-readiness)(?:\.test)?\.mjs$/,
];
const forbiddenIdentifierTerms = ['kae' + 'lio', 'Kae' + 'lio', 'KAE' + 'LIO_'];

View file

@ -79,7 +79,6 @@ describe('scanFileContent', () => {
assert.equal(scanFileContent('scripts/local-embeddings-runtime-smoke.mjs', `@${name}/ktx`).length, 0);
assert.equal(scanFileContent('scripts/package-artifacts.test.mjs', `${name}-ktx`).length, 0);
assert.equal(scanFileContent('scripts/public-npm-release-metadata.mjs', `@${name}/ktx`).length, 0);
assert.equal(scanFileContent('scripts/publish-public-npm-package.test.mjs', `@${name}/ktx`).length, 0);
assert.equal(scanFileContent('packages/cli/src/release-version.ts', `@${name}/ktx`).length, 0);
assert.equal(scanFileContent('packages/cli/src/managed-python-runtime.ts', `${name}_ktx`).length, 0);
assert.equal(scanFileContent('python/ktx-daemon/src/ktx_daemon/__init__.py', `${name}-ktx`).length, 0);

View file

@ -1,118 +0,0 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { access } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import { packageArtifactLayout } from './package-artifacts.mjs';
import { releaseReadinessReport } from './release-readiness.mjs';
export const NPM_PUBLISH_TIMEOUT_MS = 180_000;
export function resolvePublishMode(args = process.argv.slice(2)) {
return { live: args.includes('--publish') };
}
export function requireNpmPublicReleaseReady(report) {
if (report.releaseMode !== 'npm-public-release-ready' || report.npmPublishEnabled !== true || !report.npmPublish) {
throw new Error('release-policy.json must use npm-public-release-ready before publishing');
}
return report.npmPublish;
}
export function buildNpmPublishCommand(tarballPath, publish, mode) {
return {
command: 'npm',
args: [
'publish',
tarballPath,
'--access',
publish.access,
'--tag',
publish.tag,
...(mode.live ? [] : ['--dry-run']),
],
env: publish.registry ? { npm_config_registry: publish.registry } : {},
};
}
async function assertFileExists(path) {
try {
await access(path);
} catch {
throw new Error(`Missing npm tarball: ${path}. Run pnpm run artifacts:check first.`);
}
}
async function runPublishCommand(command) {
process.stdout.write(`$ ${command.command} ${command.args.join(' ')}\n`);
await new Promise((resolvePromise, reject) => {
let settled = false;
const child = spawn(command.command, command.args, {
env: { ...process.env, ...command.env },
stdio: ['ignore', 'pipe', 'pipe'],
});
const settle = (callback, value) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
callback(value);
};
const timeout = setTimeout(() => {
child.kill('SIGTERM');
settle(reject, new Error(`Timed out after ${NPM_PUBLISH_TIMEOUT_MS}ms while publishing npm package`));
}, NPM_PUBLISH_TIMEOUT_MS);
child.stdout.on('data', (chunk) => {
process.stdout.write(chunk);
});
child.stderr.on('data', (chunk) => {
process.stderr.write(chunk);
});
child.on('error', (error) => {
settle(reject, error);
});
child.on('close', (code, signal) => {
if (code === 0) {
settle(resolvePromise);
return;
}
settle(reject, new Error(`npm publish failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`));
});
});
}
export async function publishPublicNpmPackage(options = {}) {
const rootDir = options.rootDir;
const mode = options.mode ?? resolvePublishMode(options.args);
const report = await releaseReadinessReport(rootDir);
const publish = requireNpmPublicReleaseReady(report);
const layout = packageArtifactLayout(rootDir);
const tarballPath = layout.cliTarball;
await assertFileExists(tarballPath);
const command = buildNpmPublishCommand(tarballPath, publish, mode);
await runPublishCommand(command);
process.stdout.write(
mode.live
? `Published ${publish.packageName}@${publish.version} with tag ${publish.tag}\n`
: `Dry-run verified ${publish.packageName}@${publish.version} with tag ${publish.tag}\n`,
);
}
async function main() {
await publishPublicNpmPackage({ args: process.argv.slice(2) });
}
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;
}
}

View file

@ -1,108 +0,0 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { describe, it } from 'node:test';
import {
buildNpmPublishCommand,
requireNpmPublicReleaseReady,
resolvePublishMode,
} from './publish-public-npm-package.mjs';
const readyReport = {
releaseMode: 'npm-public-release-ready',
npmPublishEnabled: true,
npmPublish: {
packageName: '@kaelio/ktx',
version: '0.1.0-rc.1',
access: 'public',
tag: 'next',
registry: null,
},
};
describe('resolvePublishMode', () => {
it('dry-runs by default', () => {
assert.deepEqual(resolvePublishMode([]), { live: false });
});
it('requires an explicit flag for live publish', () => {
assert.deepEqual(resolvePublishMode(['--publish']), { live: true });
});
});
describe('requireNpmPublicReleaseReady', () => {
it('accepts the npm public release ready report', () => {
assert.equal(requireNpmPublicReleaseReady(readyReport), readyReport.npmPublish);
});
it('rejects artifact-only reports', () => {
assert.throws(
() =>
requireNpmPublicReleaseReady({
releaseMode: 'ci-artifact-only',
npmPublishEnabled: false,
npmPublish: null,
}),
/release-policy.json must use npm-public-release-ready before publishing/,
);
});
});
describe('buildNpmPublishCommand', () => {
it('builds a dry-run npm publish command by default', () => {
assert.deepEqual(
buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz', readyReport.npmPublish, {
live: false,
}),
{
command: 'npm',
args: [
'publish',
'/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz',
'--access',
'public',
'--tag',
'next',
'--dry-run',
],
env: {},
},
);
});
it('omits dry-run only for explicit live publish', () => {
assert.deepEqual(
buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz', readyReport.npmPublish, {
live: true,
}).args,
[
'publish',
'/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz',
'--access',
'public',
'--tag',
'next',
],
);
});
it('uses npm_config_registry when a registry is configured', () => {
const publish = {
...readyReport.npmPublish,
registry: 'https://registry.npmjs.org/',
};
assert.deepEqual(
buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz', publish, { live: false }).env,
{ npm_config_registry: 'https://registry.npmjs.org/' },
);
});
});
describe('package script', () => {
it('registers release:npm-publish', async () => {
const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
assert.equal(packageJson.scripts['release:npm-publish'], 'node scripts/publish-public-npm-package.mjs');
});
});

View file

@ -9,19 +9,22 @@ describe('release workflow', () => {
assert.match(workflow, /^name: KTX Release$/m);
assert.match(workflow, /^ workflow_dispatch:$/m);
assert.match(workflow, /release_kind:/);
assert.match(workflow, /options:\n - rc\n - stable/);
assert.match(workflow, /release_kind:[\s\S]*?default: "stable"/);
assert.match(workflow, /options:\n - stable\n - rc/);
assert.match(workflow, /force_release:/);
assert.match(workflow, /publish_live:/);
assert.match(workflow, /default: false/);
assert.match(workflow, /publish_live:[\s\S]*?default: true/);
assert.match(workflow, /^ contents: write$/m);
assert.match(workflow, /^ id-token: write$/m);
assert.match(workflow, /fetch-depth: 0/);
assert.match(workflow, /registry-url: "https:\/\/registry\.npmjs\.org"/);
assert.match(workflow, /Prepare first stable release floor/);
assert.match(workflow, /git tag v0\.0\.0 "\$\{root_commit\}"/);
assert.match(workflow, /KTX_STABLE_RELEASE_FLOOR_TAG=v0\.0\.0/);
assert.doesNotMatch(workflow, /Prepare first stable release floor/);
assert.doesNotMatch(workflow, /git tag v0\.0\.0/);
assert.doesNotMatch(workflow, /KTX_STABLE_RELEASE_FLOOR_TAG/);
assert.match(workflow, /Prepare next prerelease branch/);
assert.match(workflow, /git checkout -B "\$\{KTX_PRERELEASE_BRANCH\}"/);
assert.match(workflow, /Prepare npm package root for release verification/);
assert.match(workflow, /dist\/public-npm-package\/package\.json/);
assert.match(workflow, /GITHUB_REF="refs\/heads\/\$\{KTX_PRERELEASE_BRANCH\}"/);
assert.match(workflow, /pnpm run semantic-release:dry-run/);
assert.match(workflow, /pnpm run semantic-release$/m);

View file

@ -137,7 +137,6 @@ function createReleaseConfig(env = process.env) {
},
},
],
'./scripts/semantic-release-version-policy.cjs',
'@semantic-release/changelog',
[
'@semantic-release/exec',
@ -147,10 +146,19 @@ function createReleaseConfig(env = process.env) {
'pnpm run artifacts:check',
'pnpm run release:readiness',
].join(' && '),
publishCmd: [
'pnpm run release:npm-publish -- --publish',
'pnpm run release:published-smoke',
].join(' && '),
},
],
[
'@semantic-release/npm',
{
pkgRoot: 'dist/public-npm-package',
tarballDir: 'dist/artifacts/npm',
},
],
[
'@semantic-release/exec',
{
publishCmd: 'pnpm run release:published-smoke',
},
],
[

View file

@ -19,10 +19,21 @@ describe('semantic-release config', () => {
]);
const config = createReleaseConfig({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'main' });
assert.deepEqual(
config.plugins.find((plugin) => Array.isArray(plugin) && plugin[0] === '@semantic-release/npm'),
[
'@semantic-release/npm',
{
pkgRoot: 'dist/public-npm-package',
tarballDir: 'dist/artifacts/npm',
},
],
);
assert.match(
releaseExecOptions(config).prepareCmd,
/update-public-release-version\.mjs "\$\{nextRelease\.version\}" "next"/,
);
assert.doesNotMatch(releaseExecOptions(config).publishCmd ?? '', /release:npm-publish/);
});
it('configures stable releases only from main with latest tag', () => {
@ -35,7 +46,7 @@ describe('semantic-release config', () => {
releaseExecOptions(config).prepareCmd,
/update-public-release-version\.mjs "\$\{nextRelease\.version\}" "latest"/,
);
assert.ok(config.plugins.includes('./scripts/semantic-release-version-policy.cjs'));
assert.equal(config.plugins.includes('./scripts/semantic-release-version-policy.cjs'), false);
});
it('rejects stable releases from non-main branches', () => {
@ -63,5 +74,12 @@ describe('semantic-release config', () => {
analyzer[1].releaseRules.some((rule) => rule.release === 'major'),
false,
);
assert.deepEqual(
analyzer[1].releaseRules.filter((rule) => rule.breaking || rule.type === 'major'),
[
{ breaking: true, release: 'minor' },
{ type: 'major', release: 'minor' },
],
);
});
});

View file

@ -1,114 +0,0 @@
const { readFileSync } = require('node:fs');
const { join } = require('node:path');
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 FIRST_STABLE_RELEASE_FLOOR_VERSION = '0.0.0';
function parseSemver(version) {
const match = SEMVER_PATTERN.exec(version);
if (!match) {
throw new Error(`Invalid public npm package version: ${version}`);
}
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease: match[4] ?? null,
};
}
function readReleasePolicy(cwd) {
return JSON.parse(readFileSync(join(cwd, 'release-policy.json'), 'utf8'));
}
function releaseKind(env) {
return env.KTX_RELEASE_KIND || env.INPUT_RELEASE_KIND || 'rc';
}
function stableBaseVersion(version) {
const parsed = parseSemver(version);
return `${parsed.major}.${parsed.minor}.${parsed.patch}`;
}
function isFirstStableReleaseFloor(context) {
return (
releaseKind(context.env) === 'stable' &&
context.env.KTX_STABLE_RELEASE_FLOOR_TAG &&
context.lastRelease.version === FIRST_STABLE_RELEASE_FLOOR_VERSION &&
context.lastRelease.gitTag === context.env.KTX_STABLE_RELEASE_FLOOR_TAG
);
}
function analyzeCommits(config, context) {
if (!isFirstStableReleaseFloor(context)) {
return undefined;
}
context.logger.log('Using temporary stable release floor to publish 0.1.0');
return 'minor';
}
function assertNoAutomaticMajorRelease(context, policyVersion) {
const policy = parseSemver(policyVersion);
const next = parseSemver(context.nextRelease.version);
if (next.major <= policy.major) {
return;
}
throw new Error(
[
`Refusing automatic major release ${context.nextRelease.version}.`,
`release-policy.json is still on major ${policy.major}.`,
'Update release-policy.json manually before publishing a new major version.',
].join(' '),
);
}
function assertStableReleaseFloorTarget(context, policyVersion) {
if (!isFirstStableReleaseFloor(context)) {
return;
}
const expectedVersion = stableBaseVersion(policyVersion);
if (context.nextRelease.version !== expectedVersion) {
throw new Error(
`Stable release floor expected ${expectedVersion}, got ${context.nextRelease.version}.`,
);
}
}
function verifyRelease(config, context) {
const policy = readReleasePolicy(context.cwd);
const policyVersion = policy.publicNpmPackageVersion;
assertNoAutomaticMajorRelease(context, policyVersion);
assertStableReleaseFloorTarget(context, policyVersion);
}
function prepare(config, context) {
const floorTag = context.env.KTX_STABLE_RELEASE_FLOOR_TAG;
if (!floorTag) {
return;
}
const { execFileSync } = require('node:child_process');
execFileSync('git', ['tag', '-d', floorTag], {
cwd: context.cwd,
stdio: 'ignore',
});
context.logger.log(`Deleted temporary stable release floor tag ${floorTag}`);
}
module.exports = {
FIRST_STABLE_RELEASE_FLOOR_VERSION,
analyzeCommits,
assertNoAutomaticMajorRelease,
assertStableReleaseFloorTarget,
isFirstStableReleaseFloor,
parseSemver,
prepare,
stableBaseVersion,
verifyRelease,
};

View file

@ -1,123 +0,0 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it } from 'node:test';
const require = createRequire(import.meta.url);
const {
analyzeCommits,
parseSemver,
stableBaseVersion,
verifyRelease,
} = require('./semantic-release-version-policy.cjs');
async function writePolicy(root, version) {
await mkdir(root, { recursive: true });
await writeFile(
join(root, 'release-policy.json'),
`${JSON.stringify({ publicNpmPackageVersion: version }, null, 2)}\n`,
);
}
function releaseContext(root, overrides = {}) {
return {
cwd: root,
env: { KTX_RELEASE_KIND: 'stable' },
lastRelease: {},
logger: { log() {} },
nextRelease: {
version: '1.0.0',
gitTag: 'v1.0.0',
name: 'v1.0.0',
},
options: { tagFormat: 'v${version}' },
...overrides,
};
}
describe('semantic-release version policy', () => {
it('parses semver versions used by public release metadata', () => {
assert.deepEqual(parseSemver('0.1.0-rc.6'), {
major: 0,
minor: 1,
patch: 0,
prerelease: 'rc.6',
});
assert.equal(stableBaseVersion('0.1.0-rc.6'), '0.1.0');
});
it('uses the temporary stable release floor to make 0.1.0 a minor release', async () => {
const context = releaseContext('/repo/ktx', {
env: {
KTX_RELEASE_KIND: 'stable',
KTX_STABLE_RELEASE_FLOOR_TAG: 'v0.0.0',
},
lastRelease: {
version: '0.0.0',
gitTag: 'v0.0.0',
},
});
assert.equal(analyzeCommits({}, context), 'minor');
});
it('accepts the first stable release from the current public rc base version', async () => {
const root = await mkdtemp(join(tmpdir(), 'ktx-release-version-policy-'));
try {
await writePolicy(root, '0.1.0-rc.6');
const context = releaseContext(root, {
env: {
KTX_RELEASE_KIND: 'stable',
KTX_STABLE_RELEASE_FLOOR_TAG: 'v0.0.0',
},
lastRelease: {
version: '0.0.0',
gitTag: 'v0.0.0',
},
nextRelease: {
version: '0.1.0',
gitTag: 'v0.1.0',
name: 'v0.1.0',
},
});
verifyRelease({}, context);
assert.equal(context.nextRelease.version, '0.1.0');
} finally {
await rm(root, { recursive: true, force: true });
}
});
it('rejects automatic major releases until release metadata is manually advanced', async () => {
const root = await mkdtemp(join(tmpdir(), 'ktx-release-version-policy-'));
try {
await writePolicy(root, '0.1.0');
const context = releaseContext(root, {
lastRelease: { gitTag: 'v0.1.0' },
});
assert.throws(() => verifyRelease({}, context), /Refusing automatic major release 1\.0\.0/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it('allows major releases when release metadata was manually advanced first', async () => {
const root = await mkdtemp(join(tmpdir(), 'ktx-release-version-policy-'));
try {
await writePolicy(root, '1.0.0');
const context = releaseContext(root, {
lastRelease: { gitTag: 'v0.1.0' },
});
verifyRelease({}, context);
assert.equal(context.nextRelease.version, '1.0.0');
} finally {
await rm(root, { recursive: true, force: true });
}
});
});