chore: add semantic release workflow

This commit is contained in:
Andrey Avtomonov 2026-05-16 01:49:23 +02:00
parent 5073a76a5b
commit a11f7a06ae
18 changed files with 2822 additions and 56 deletions

View file

@ -3,14 +3,27 @@ name: KTX Release
on:
workflow_dispatch:
inputs:
release_kind:
description: "Release kind: rc publishes to next, stable publishes to latest"
required: true
type: choice
default: "rc"
options:
- rc
- stable
force_release:
description: "Force a patch release even if semantic-release finds no releasable commits"
required: false
type: boolean
default: false
publish_live:
description: "Publish @kaelio/ktx to npm instead of running a dry-run"
description: "Create the release and publish @kaelio/ktx to npm instead of running a dry-run"
required: true
type: boolean
default: false
permissions:
contents: read
contents: write
concurrency:
group: ktx-release-${{ github.ref }}
@ -22,6 +35,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
@ -34,6 +49,7 @@ jobs:
node-version: "24"
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
registry-url: "https://registry.npmjs.org"
- name: Install TypeScript dependencies
run: pnpm install --frozen-lockfile
@ -52,18 +68,19 @@ jobs:
- name: Install Python dependencies
run: uv sync --all-packages
- name: Build and verify artifacts
run: pnpm run artifacts:check
- name: Check release readiness
run: pnpm run release:readiness
- name: Dry-run npm publish
- name: Dry-run semantic release
if: ${{ !inputs.publish_live }}
run: pnpm run release:npm-publish
- name: Publish npm package
if: ${{ inputs.publish_live }}
run: pnpm run release:npm-publish -- --publish
run: pnpm run semantic-release:dry-run
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
KTX_RELEASE_KIND: ${{ inputs.release_kind }}
FORCE_RELEASE: ${{ inputs.force_release }}
- name: Create semantic release
if: ${{ inputs.publish_live }}
run: pnpm run semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
KTX_RELEASE_KIND: ${{ inputs.release_kind }}
FORCE_RELEASE: ${{ inputs.force_release }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

3
.releaserc.cjs Normal file
View file

@ -0,0 +1,3 @@
const { createReleaseConfig } = require('./scripts/semantic-release-config.cjs');
module.exports = createReleaseConfig(process.env);

99
docs/release.md Normal file
View file

@ -0,0 +1,99 @@
# KTX release runbook
This runbook covers the maintainer workflow for publishing `@kaelio/ktx` to
npm through GitHub Actions. The workflow uses semantic-release to choose the
next version, update release metadata, publish the package, create the GitHub
release, and commit the release files back to the repository.
## Release channels
KTX has two npm release channels:
- `rc` publishes prereleases such as `0.1.0-rc.2` to the npm `next` tag.
- `stable` publishes normal releases such as `0.1.0` to the npm `latest` tag.
Run stable releases only from `main`. The workflow rejects stable releases from
other branches.
## Prerequisites
Before you publish, confirm these requirements:
- The repository has an Actions secret named `NPM_TOKEN`.
- `NPM_TOKEN` is a granular npm token that can publish `@kaelio/ktx`.
- The token can publish non-interactively if the npm account or package uses
two-factor authentication for writes.
- The repository has a baseline semantic-release tag for the latest published
package version, such as `v0.1.0-rc.1`.
If no baseline tag exists, semantic-release treats the run as the first release
and may choose a version that doesn't match the currently published package.
## Dry-run a release
Use a dry-run to verify the next version and generated release notes without
publishing to npm.
1. Open **Actions** in GitHub.
2. Select **KTX Release**.
3. Select the branch to release from.
4. Set **release_kind** to `rc` or `stable`.
5. Leave **publish_live** set to `false`.
6. Optional: Set **force_release** to `true` when you need a patch release even
if semantic-release doesn't find a releasable commit.
7. Run the workflow.
The dry-run uses the same semantic-release configuration as a live release. It
doesn't publish to npm and doesn't commit release files.
## Publish an rc release
Publish an rc release when you need a prerelease package for validation before
promoting to `latest`.
1. Open **Actions** in GitHub.
2. Select **KTX Release**.
3. Select the branch to release from.
4. Set **release_kind** to `rc`.
5. Set **publish_live** to `true`.
6. Optional: Set **force_release** to `true`.
7. Run the workflow.
The workflow publishes `@kaelio/ktx` with `--access public --tag next`, runs the
published package smoke test, creates a GitHub release, and commits
`CHANGELOG.md`, `package.json`, and `release-policy.json`.
## Publish a stable release
Publish a stable release from `main` after you have validated an rc package.
1. Open **Actions** in GitHub.
2. Select **KTX Release**.
3. Select `main`.
4. Set **release_kind** to `stable`.
5. Set **publish_live** to `true`.
6. Optional: Set **force_release** to `true`.
7. Run the workflow.
The workflow publishes `@kaelio/ktx` with `--access public --tag latest`, runs
the published package smoke test, creates a GitHub release, and commits the
release metadata.
## Release metadata
semantic-release calls `scripts/update-public-release-version.mjs` during the
prepare step. That script updates:
- `package.json` with the semantic-release version.
- `release-policy.json` with `publicNpmPackageVersion`, npm publish settings,
and the published package smoke-test version.
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.
## Trusted Publishing follow-up
This workflow uses `NPM_TOKEN` today. Move to npm Trusted Publishing after the
final publish command path is verified for the package manager and workflow
filename configured in npm package settings.

View file

@ -3,7 +3,13 @@
"workspaces": {
".": {
"entry": ["scripts/**/*.mjs"],
"project": ["scripts/**/*.mjs"]
"project": ["scripts/**/*.mjs"],
"ignoreDependencies": [
"@semantic-release/commit-analyzer",
"@semantic-release/github",
"@semantic-release/release-notes-generator",
"conventional-changelog-conventionalcommits"
]
},
"packages/cli": {
"entry": [

View file

@ -30,10 +30,14 @@
"release:local-embeddings-smoke": "node scripts/local-embeddings-runtime-smoke.mjs --require-opt-in",
"release:npm-publish": "node scripts/publish-public-npm-package.mjs",
"release:readiness": "node scripts/release-readiness.mjs",
"release:update-version": "node scripts/update-public-release-version.mjs",
"relationships:acquire-public-fixtures": "node scripts/acquire-public-benchmark-fixtures.mjs",
"relationships:rebuild-public-snapshots": "node scripts/build-benchmark-snapshot.mjs --rebuild-all",
"relationships:build-adventureworks-oltp": "node scripts/build-adventureworks-oltp-fixture.mjs",
"relationships:verify-orbit": "node scripts/relationship-orbit-verification.mjs",
"semantic-release": "semantic-release",
"semantic-release:debug": "semantic-release --dry-run --debug",
"semantic-release:dry-run": "semantic-release --dry-run --no-ci",
"smoke": "pnpm run build && pnpm --filter @ktx/cli run smoke",
"test": "node --test scripts/*.test.mjs && pnpm --filter './packages/*' run test",
"test:coverage": "pnpm run test:coverage:ts && pnpm run test:coverage:py",
@ -44,9 +48,17 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.15",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/exec": "^7.1.0",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.8",
"@semantic-release/release-notes-generator": "^14.1.1",
"@types/node": "^25.7.0",
"better-sqlite3": "^12.10.0",
"conventional-changelog-conventionalcommits": "^9.3.1",
"knip": "^6.12.2",
"semantic-release": "^25.0.3",
"typescript": "^6.0.3",
"yaml": "^2.9.0"
},

2113
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
{
"schemaVersion": 1,
"publicNpmPackageVersion": "0.1.0-rc.1",
"releaseMode": "npm-public-release-ready",
"npm": {
"publish": true,

View file

@ -6,10 +6,15 @@ import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { promisify } from 'node:util';
import {
PUBLIC_NPM_PACKAGE_NAME,
publicNpmPackageVersion,
} from './public-npm-release-metadata.mjs';
const execFileAsync = promisify(execFile);
export const PUBLIC_NPM_PACKAGE_NAME = '@kaelio/ktx';
export const PUBLIC_NPM_PACKAGE_VERSION = '0.1.0-rc.1';
export const PUBLIC_NPM_PACKAGE_VERSION = publicNpmPackageVersion();
export { PUBLIC_NPM_PACKAGE_NAME };
export function publicNpmPackageTarballName(version = PUBLIC_NPM_PACKAGE_VERSION) {
return `kaelio-ktx-${version}.tgz`;

View file

@ -14,9 +14,9 @@ import {
} from './build-python-runtime-wheel.mjs';
import {
PUBLIC_NPM_PACKAGE_NAME,
PUBLIC_NPM_PACKAGE_VERSION,
publicNpmPackageTarballName,
} from './build-public-npm-package.mjs';
import { publicNpmPackageVersion } from './public-npm-release-metadata.mjs';
export {
RUNTIME_WHEEL_DISTRIBUTION_NAME,
@ -45,24 +45,27 @@ function scriptRootDir() {
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
}
function npmPackageTarballName(packageName) {
function npmPackageTarballName(packageName, version) {
if (packageName !== PUBLIC_NPM_PACKAGE_NAME) {
throw new Error(`Unsupported npm artifact package: ${packageName}`);
}
return publicNpmPackageTarballName(PUBLIC_NPM_PACKAGE_VERSION);
return publicNpmPackageTarballName(version);
}
function npmPackageTarballs(npmDir) {
function npmPackageTarballs(npmDir, version) {
return Object.fromEntries(
NPM_ARTIFACT_PACKAGES.map((packageInfo) => [packageInfo.name, join(npmDir, npmPackageTarballName(packageInfo.name))]),
NPM_ARTIFACT_PACKAGES.map((packageInfo) => [
packageInfo.name,
join(npmDir, npmPackageTarballName(packageInfo.name, version)),
]),
);
}
export function packageArtifactLayout(rootDir = scriptRootDir()) {
export function packageArtifactLayout(rootDir = scriptRootDir(), version = publicNpmPackageVersion(rootDir)) {
const artifactDir = join(rootDir, 'dist', 'artifacts');
const npmDir = join(artifactDir, 'npm');
const pythonDir = join(artifactDir, 'python');
const npmTarballs = npmPackageTarballs(npmDir);
const npmTarballs = npmPackageTarballs(npmDir, version);
return {
rootDir,
@ -170,7 +173,7 @@ function releaseMetadataEntry({ ecosystem, packageName, packageRoot, packageVers
};
}
async function readNpmPackageMetadata(rootDir, packageInfo) {
async function readNpmPackageMetadata(rootDir, packageInfo, version) {
const packageJson = await readJson(join(rootDir, packageInfo.packageRoot, 'package.json'));
const expectedSourceName = packageInfo.name === PUBLIC_NPM_PACKAGE_NAME ? '@ktx/cli' : packageInfo.name;
if (packageJson.name !== expectedSourceName) {
@ -183,14 +186,14 @@ async function readNpmPackageMetadata(rootDir, packageInfo) {
ecosystem: 'npm',
packageName: packageInfo.name,
packageRoot: packageInfo.packageRoot,
packageVersion: isPublicKtxPackage ? PUBLIC_NPM_PACKAGE_VERSION : packageJson.version,
packageVersion: isPublicKtxPackage ? version : packageJson.version,
privatePackage: isPublicKtxPackage ? false : packageJson.private === true,
});
}
export async function packageReleaseMetadata(rootDir = scriptRootDir()) {
export async function packageReleaseMetadata(rootDir = scriptRootDir(), version = publicNpmPackageVersion(rootDir)) {
const npmPackages = await Promise.all(
NPM_ARTIFACT_PACKAGES.map((packageInfo) => readNpmPackageMetadata(rootDir, packageInfo)),
NPM_ARTIFACT_PACKAGES.map((packageInfo) => readNpmPackageMetadata(rootDir, packageInfo, version)),
);
return [

View file

@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it } from 'node:test';
import { PUBLIC_NPM_PACKAGE_VERSION } from './build-public-npm-package.mjs';
import {
CLI_PYTHON_ASSET_MANIFEST,
INTERNAL_NPM_WORKSPACE_PACKAGES,
@ -32,6 +33,35 @@ 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,
registry: null,
access: 'public',
tag: 'next',
packages: ['@kaelio/ktx'],
},
python: {
publish: false,
repository: null,
packages: ['kaelio-ktx'],
},
publishedPackageSmoke: {
packageName: '@kaelio/ktx',
version: PUBLIC_NPM_PACKAGE_VERSION,
registry: null,
},
runtimeInstaller: {
uvStrategy: 'path-prerequisite',
bootstrapUv: false,
missingUvBehavior: 'focused-error',
},
requiredBeforePublishing: ['Choose public release version.'],
});
for (const packageInfo of INTERNAL_NPM_WORKSPACE_PACKAGES) {
await mkdir(join(root, packageInfo.packageRoot), { recursive: true });
await writeJson(join(root, packageInfo.packageRoot, 'package.json'), {
@ -64,7 +94,7 @@ async function writeUploadableArtifactFixtures(layout) {
describe('packageArtifactLayout', () => {
it('uses stable artifact paths under ktx/dist/artifacts', () => {
const layout = packageArtifactLayout('/repo/ktx');
const layout = packageArtifactLayout('/repo/ktx', PUBLIC_NPM_PACKAGE_VERSION);
assert.equal(layout.artifactDir, '/repo/ktx/dist/artifacts');
assert.equal(layout.npmDir, '/repo/ktx/dist/artifacts/npm');
@ -76,7 +106,7 @@ describe('packageArtifactLayout', () => {
describe('buildArtifactCommands', () => {
it('builds TypeScript packages in parallel topology, then the runtime wheel, then packs npm artifacts', () => {
const layout = packageArtifactLayout('/repo/ktx');
const layout = packageArtifactLayout('/repo/ktx', PUBLIC_NPM_PACKAGE_VERSION);
const commands = buildArtifactCommands(layout);
assert.deepEqual(
@ -147,7 +177,7 @@ describe('findPythonArtifacts', () => {
describe('artifact manifest', () => {
it('writes release metadata, source revision, checksums, and byte counts for every uploadable artifact', async () => {
const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-manifest-test-'));
const layout = packageArtifactLayout(root);
const layout = packageArtifactLayout(root, PUBLIC_NPM_PACKAGE_VERSION);
try {
await writeReleaseMetadataInputs(root);
await writeUploadableArtifactFixtures(layout);
@ -244,7 +274,7 @@ describe('artifact manifest', () => {
describe('verifyArtifactManifest', () => {
it('accepts a schema version 2 manifest that matches the artifact directory', async () => {
const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-verify-manifest-test-'));
const layout = packageArtifactLayout(root);
const layout = packageArtifactLayout(root, PUBLIC_NPM_PACKAGE_VERSION);
try {
await writeReleaseMetadataInputs(root);
await writeUploadableArtifactFixtures(layout);
@ -266,7 +296,7 @@ describe('verifyArtifactManifest', () => {
it('rejects a manifest when a file checksum has drifted', async () => {
const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-checksum-drift-test-'));
const layout = packageArtifactLayout(root);
const layout = packageArtifactLayout(root, PUBLIC_NPM_PACKAGE_VERSION);
try {
await writeReleaseMetadataInputs(root);
await writeUploadableArtifactFixtures(layout);
@ -286,7 +316,7 @@ describe('verifyArtifactManifest', () => {
it('rejects a manifest with an unsafe artifact path', async () => {
const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-path-test-'));
const layout = packageArtifactLayout(root);
const layout = packageArtifactLayout(root, PUBLIC_NPM_PACKAGE_VERSION);
try {
await writeReleaseMetadataInputs(root);
await writeUploadableArtifactFixtures(layout);
@ -304,7 +334,7 @@ describe('verifyArtifactManifest', () => {
it('rejects a manifest from the wrong source revision when one is required', async () => {
const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-revision-test-'));
const layout = packageArtifactLayout(root);
const layout = packageArtifactLayout(root, PUBLIC_NPM_PACKAGE_VERSION);
try {
await writeReleaseMetadataInputs(root);
await writeUploadableArtifactFixtures(layout);
@ -328,7 +358,7 @@ describe('verifyArtifactManifest', () => {
describe('copyRuntimeWheelAssets', () => {
it('copies the runtime wheel and checksum manifest into CLI assets', async () => {
const root = await mkdtemp(join(tmpdir(), 'ktx-runtime-assets-test-'));
const layout = packageArtifactLayout(root);
const layout = packageArtifactLayout(root, PUBLIC_NPM_PACKAGE_VERSION);
try {
await mkdir(layout.pythonDir, { recursive: true });
await writeFile(
@ -399,7 +429,7 @@ describe('standalone Python artifact cleanup', () => {
describe('verification snippets', () => {
it('pins the smoke project to the public package artifact', () => {
const layout = packageArtifactLayout('/repo/ktx');
const layout = packageArtifactLayout('/repo/ktx', PUBLIC_NPM_PACKAGE_VERSION);
const packageJson = npmSmokePackageJson(layout);
assert.deepEqual(packageJson.dependencies, {

View file

@ -0,0 +1,53 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
export const PUBLIC_NPM_PACKAGE_NAME = '@kaelio/ktx';
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-]+)*)?$/;
function scriptRootDir() {
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
}
export function releasePolicyPath(rootDir = scriptRootDir()) {
return join(rootDir, 'release-policy.json');
}
function readJsonSync(path) {
return JSON.parse(readFileSync(path, 'utf8'));
}
export function assertPublicNpmPackageVersion(version) {
if (typeof version !== 'string' || !SEMVER_PATTERN.test(version)) {
throw new Error(`Invalid public npm package version: ${version}`);
}
return version;
}
export function assertPublicNpmReleaseTag(tag) {
if (!PUBLIC_NPM_RELEASE_TAGS.has(tag)) {
throw new Error(`Invalid public npm release tag: ${tag}`);
}
return tag;
}
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,
tag,
};
}
export function publicNpmPackageVersion(rootDir = scriptRootDir()) {
return readPublicNpmReleaseMetadata(rootDir).version;
}

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 { PUBLIC_NPM_PACKAGE_VERSION } from './build-public-npm-package.mjs';
import { assertPublicNpmPackageVersion, publicNpmPackageVersion } from './public-npm-release-metadata.mjs';
import { readPublishedPackageSmokeConfig } from './published-package-smoke-config.mjs';
function scriptRootDir() {
@ -138,6 +138,8 @@ 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');
@ -202,7 +204,7 @@ function publishedPackageSmokeGate(policy) {
};
}
function assertNonPublishingArtifactPolicy(policy, metadata) {
function assertNonPublishingArtifactPolicy(policy, metadata, publicPackageVersion) {
const policyLabel =
policy.releaseMode === CI_ARTIFACT_ONLY_RELEASE_MODE ? 'ci-artifact-only policy' : `${policy.releaseMode} policy`;
@ -232,8 +234,8 @@ function assertNonPublishingArtifactPolicy(policy, metadata) {
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}`);
if (entry.packageVersion !== publicPackageVersion) {
throw new Error(`${policyLabel} npm package @kaelio/ktx must use public version ${publicPackageVersion}`);
}
} else if (entry.private !== true) {
throw new Error(`${policyLabel} npm package ${entry.packageName} must remain private`);
@ -244,7 +246,7 @@ function assertNonPublishingArtifactPolicy(policy, metadata) {
}
}
function assertNpmPublicReleaseReadyPolicy(policy, metadata) {
function assertNpmPublicReleaseReadyPolicy(policy, metadata, publicPackageVersion) {
if (policy.npm.publish !== true) {
throw new Error('npm-public-release-ready policy requires npm.publish true');
}
@ -265,29 +267,30 @@ function assertNpmPublicReleaseReadyPolicy(policy, 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) {
if (npmMetadata.packageVersion !== publicPackageVersion) {
throw new Error(
`npm-public-release-ready policy expected @kaelio/ktx ${PUBLIC_NPM_PACKAGE_VERSION}, got ${npmMetadata.packageVersion}`,
`npm-public-release-ready policy expected @kaelio/ktx ${publicPackageVersion}, 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}`);
if (policy.publishedPackageSmoke.version !== publicPackageVersion) {
throw new Error(`npm-public-release-ready policy requires publishedPackageSmoke.version ${publicPackageVersion}`);
}
}
export async function releaseReadinessReport(rootDir = scriptRootDir()) {
const policy = validateReleasePolicy(await readReleasePolicy(rootDir));
const layout = packageArtifactLayout(rootDir);
const publicPackageVersion = publicNpmPackageVersion(rootDir);
const layout = packageArtifactLayout(rootDir, publicPackageVersion);
const manifest = await verifyArtifactManifest(layout);
const metadata = await packageReleaseMetadata(rootDir);
const metadata = await packageReleaseMetadata(rootDir, publicPackageVersion);
if (policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE) {
assertNpmPublicReleaseReadyPolicy(policy, metadata);
assertNpmPublicReleaseReadyPolicy(policy, metadata, publicPackageVersion);
} else {
assertNonPublishingArtifactPolicy(policy, metadata);
assertNonPublishingArtifactPolicy(policy, metadata, publicPackageVersion);
}
return {
@ -303,7 +306,7 @@ export async function releaseReadinessReport(rootDir = scriptRootDir()) {
policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE
? {
packageName: '@kaelio/ktx',
version: PUBLIC_NPM_PACKAGE_VERSION,
version: publicPackageVersion,
access: policy.npm.access,
tag: policy.npm.tag,
registry: policy.npm.registry,

View file

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

View file

@ -3,17 +3,23 @@ import { readFile } from 'node:fs/promises';
import { describe, it } from 'node:test';
describe('release workflow', () => {
it('publishes only from manual dispatch with an explicit live input', async () => {
it('runs semantic-release only from manual dispatch with explicit release inputs', async () => {
const workflow = await readFile(new URL('../.github/workflows/release.yml', import.meta.url), 'utf8');
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, /force_release:/);
assert.match(workflow, /publish_live:/);
assert.match(workflow, /default: false/);
assert.match(workflow, /pnpm run artifacts:check/);
assert.match(workflow, /pnpm run release:readiness/);
assert.match(workflow, /pnpm run release:npm-publish$/m);
assert.match(workflow, /pnpm run release:npm-publish -- --publish/);
assert.match(workflow, /^ contents: write$/m);
assert.match(workflow, /fetch-depth: 0/);
assert.match(workflow, /registry-url: "https:\/\/registry\.npmjs\.org"/);
assert.match(workflow, /pnpm run semantic-release:dry-run/);
assert.match(workflow, /pnpm run semantic-release$/m);
assert.match(workflow, /KTX_RELEASE_KIND: \$\{\{ inputs.release_kind \}\}/);
assert.match(workflow, /FORCE_RELEASE: \$\{\{ inputs.force_release \}\}/);
assert.match(workflow, /NODE_AUTH_TOKEN: \$\{\{ secrets.NPM_TOKEN \}\}/);
assert.doesNotMatch(workflow, /^ push:/m);
assert.doesNotMatch(workflow, /^ pull_request:/m);

View file

@ -0,0 +1,176 @@
const releaseRules = [
{ breaking: true, release: 'minor' },
{ revert: true, release: 'patch' },
{ type: 'feat', release: 'minor' },
{ type: 'feature', release: 'minor' },
{ type: 'enhancement', release: 'minor' },
{ type: 'fix', release: 'patch' },
{ type: 'bug', release: 'patch' },
{ type: 'bugfix', release: 'patch' },
{ type: 'patch', release: 'patch' },
{ type: 'perf', release: 'patch' },
{ type: 'performance', release: 'patch' },
{ type: 'optimization', release: 'patch' },
{ type: 'security', release: 'patch' },
{ type: 'vulnerability', release: 'patch' },
{ type: 'deps', release: 'patch' },
{ type: 'dependencies', release: 'patch' },
{ type: 'upgrade', release: 'patch' },
{ type: 'update', release: 'patch' },
{ type: 'style', release: 'patch' },
{ type: 'refactor', release: 'patch' },
{ type: 'refactoring', release: 'patch' },
{ type: 'cleanup', release: 'patch' },
{ type: 'test', release: 'patch' },
{ type: 'tests', release: 'patch' },
{ type: 'testing', release: 'patch' },
{ type: 'build', release: 'patch' },
{ type: 'ci', release: 'patch' },
{ type: 'cd', release: 'patch' },
{ type: 'config', release: 'patch' },
{ type: 'workflow', release: 'patch' },
{ type: 'pipeline', release: 'patch' },
{ type: 'chore', release: 'patch' },
{ type: 'docs', release: 'patch' },
{ type: 'documentation', release: 'patch' },
{ type: 'breaking', release: 'minor' },
{ type: 'breaking-change', release: 'minor' },
{ type: 'major', release: 'minor' },
];
const releaseNoteTypes = [
{ type: 'feat', section: 'Features', hidden: false },
{ type: 'feature', section: 'Features', hidden: false },
{ type: 'fix', section: 'Bug Fixes', hidden: false },
{ type: 'bug', section: 'Bug Fixes', hidden: false },
{ type: 'bugfix', section: 'Bug Fixes', hidden: false },
{ type: 'perf', section: 'Performance Improvements', hidden: false },
{ type: 'performance', section: 'Performance Improvements', hidden: false },
{ type: 'optimization', section: 'Performance Improvements', hidden: false },
{ type: 'security', section: 'Security', hidden: false },
{ type: 'vulnerability', section: 'Security', hidden: false },
{ type: 'deps', section: 'Dependencies', hidden: false },
{ type: 'dependencies', section: 'Dependencies', hidden: false },
{ type: 'upgrade', section: 'Dependencies', hidden: false },
{ type: 'update', section: 'Dependencies', hidden: false },
{ type: 'docs', section: 'Documentation', hidden: false },
{ type: 'documentation', section: 'Documentation', hidden: false },
{ type: 'style', section: 'Styling', hidden: false },
{ type: 'refactor', section: 'Code Refactoring', hidden: false },
{ type: 'refactoring', section: 'Code Refactoring', hidden: false },
{ type: 'cleanup', section: 'Code Refactoring', hidden: false },
{ type: 'test', section: 'Tests', hidden: false },
{ type: 'tests', section: 'Tests', hidden: false },
{ type: 'testing', section: 'Tests', hidden: false },
{ type: 'build', section: 'Build System', hidden: false },
{ type: 'ci', section: 'Continuous Integration', hidden: false },
{ type: 'cd', section: 'Continuous Integration', hidden: false },
{ type: 'config', section: 'Configuration', hidden: false },
{ type: 'workflow', section: 'Continuous Integration', hidden: false },
{ type: 'pipeline', section: 'Continuous Integration', hidden: false },
{ type: 'chore', section: 'Other Changes', hidden: false },
{ type: 'breaking', section: 'BREAKING CHANGES', hidden: false },
{ type: 'breaking-change', section: 'BREAKING CHANGES', hidden: false },
{ type: 'major', section: 'BREAKING CHANGES', hidden: false },
];
function currentBranch(env) {
return env.GITHUB_REF_NAME || env.INPUT_BRANCH || 'main';
}
function releaseKind(env) {
return env.KTX_RELEASE_KIND || env.INPUT_RELEASE_KIND || 'rc';
}
function releaseTag(kind) {
return kind === 'rc' ? 'next' : 'latest';
}
function releaseBranches(env = process.env) {
const branch = currentBranch(env);
const kind = releaseKind(env);
if (kind === 'rc') {
return [{ name: branch, prerelease: 'rc', channel: 'next' }];
}
if (kind === 'stable') {
if (branch !== 'main') {
throw new Error(`Stable KTX releases must run from main, got ${branch}`);
}
return ['main'];
}
throw new Error(`Unsupported KTX_RELEASE_KIND: ${kind}`);
}
function createReleaseConfig(env = process.env) {
const kind = releaseKind(env);
const tag = releaseTag(kind);
return {
tagFormat: 'v${version}',
branches: releaseBranches(env),
plugins: [
[
'@semantic-release/commit-analyzer',
{
releaseRules,
},
],
[
'@semantic-release/exec',
{
analyzeCommitsCmd: 'node -e "console.log(process.env.FORCE_RELEASE === \'true\' ? \'patch\' : \'\')"',
},
],
[
'@semantic-release/release-notes-generator',
{
preset: 'conventionalcommits',
presetConfig: {
types: releaseNoteTypes,
},
},
],
'@semantic-release/changelog',
[
'@semantic-release/exec',
{
prepareCmd: [
`node scripts/update-public-release-version.mjs "\${nextRelease.version}" "${tag}"`,
'pnpm run artifacts:check',
'pnpm run release:readiness',
].join(' && '),
publishCmd: [
'pnpm run release:npm-publish -- --publish',
'pnpm run release:published-smoke',
].join(' && '),
},
],
[
'@semantic-release/git',
{
assets: ['CHANGELOG.md', 'package.json', 'release-policy.json'],
message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
},
],
[
'@semantic-release/github',
{
successComment: false,
failComment: false,
failTitle: false,
releasedLabels: false,
},
],
],
};
}
module.exports = {
createReleaseConfig,
releaseBranches,
releaseKind,
releaseTag,
};

View file

@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { describe, it } from 'node:test';
const require = createRequire(import.meta.url);
const { createReleaseConfig, releaseBranches, releaseKind, releaseTag } = require('./semantic-release-config.cjs');
function releaseExecOptions(config) {
return config.plugins.find((plugin) => Array.isArray(plugin) && plugin[0] === '@semantic-release/exec' && plugin[1].prepareCmd)[1];
}
describe('semantic-release config', () => {
it('configures manual rc releases on the selected branch with next channel', () => {
assert.equal(releaseKind({ KTX_RELEASE_KIND: 'rc' }), 'rc');
assert.equal(releaseTag('rc'), 'next');
assert.deepEqual(releaseBranches({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'release-candidate' }), [
{ name: 'release-candidate', prerelease: 'rc', channel: 'next' },
]);
const config = createReleaseConfig({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'release-candidate' });
assert.match(
releaseExecOptions(config).prepareCmd,
/update-public-release-version\.mjs "\$\{nextRelease\.version\}" "next"/,
);
});
it('configures stable releases only from main with latest tag', () => {
assert.equal(releaseKind({ KTX_RELEASE_KIND: 'stable' }), 'stable');
assert.equal(releaseTag('stable'), 'latest');
assert.deepEqual(releaseBranches({ KTX_RELEASE_KIND: 'stable', GITHUB_REF_NAME: 'main' }), ['main']);
const config = createReleaseConfig({ KTX_RELEASE_KIND: 'stable', GITHUB_REF_NAME: 'main' });
assert.match(
releaseExecOptions(config).prepareCmd,
/update-public-release-version\.mjs "\$\{nextRelease\.version\}" "latest"/,
);
});
it('rejects stable releases from non-main branches', () => {
assert.throws(
() => releaseBranches({ KTX_RELEASE_KIND: 'stable', GITHUB_REF_NAME: 'feature/release-test' }),
/Stable KTX releases must run from main, got feature\/release-test/,
);
});
it('keeps the force-release patch escape hatch', () => {
const config = createReleaseConfig({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'main' });
const analyzeExec = config.plugins.find(
(plugin) => Array.isArray(plugin) && plugin[0] === '@semantic-release/exec' && plugin[1].analyzeCommitsCmd,
);
assert.match(analyzeExec[1].analyzeCommitsCmd, /FORCE_RELEASE === 'true' \? 'patch' : ''/);
});
});

View file

@ -0,0 +1,78 @@
#!/usr/bin/env node
import { readFile, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import {
PUBLIC_NPM_PACKAGE_NAME,
assertPublicNpmPackageVersion,
assertPublicNpmReleaseTag,
releasePolicyPath,
} from './public-npm-release-metadata.mjs';
function scriptRootDir() {
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
}
async function readJson(path) {
return JSON.parse(await readFile(path, 'utf8'));
}
async function writeJson(path, value) {
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
}
export async function updatePublicReleaseVersion(rootDir, version, tag) {
const safeVersion = assertPublicNpmPackageVersion(version);
const safeTag = assertPublicNpmReleaseTag(tag);
const packageJsonPath = join(rootDir, 'package.json');
const packageJson = await readJson(packageJsonPath);
packageJson.version = safeVersion;
await writeJson(packageJsonPath, packageJson);
const policyPath = releasePolicyPath(rootDir);
const policy = await readJson(policyPath);
policy.publicNpmPackageVersion = safeVersion;
policy.releaseMode = 'npm-public-release-ready';
policy.requiredBeforePublishing = [];
policy.npm = {
...policy.npm,
publish: true,
registry: policy.npm?.registry ?? null,
access: 'public',
tag: safeTag,
packages: [PUBLIC_NPM_PACKAGE_NAME],
};
policy.publishedPackageSmoke = {
...policy.publishedPackageSmoke,
packageName: PUBLIC_NPM_PACKAGE_NAME,
version: safeVersion,
};
await writeJson(policyPath, policy);
return {
version: safeVersion,
tag: safeTag,
};
}
async function main() {
const [version, tag] = process.argv.slice(2);
if (!version || !tag) {
throw new Error('Usage: node scripts/update-public-release-version.mjs <version> <latest|next>');
}
const result = await updatePublicReleaseVersion(scriptRootDir(), version, tag);
process.stdout.write(`Updated ${PUBLIC_NPM_PACKAGE_NAME} release metadata to ${result.version} (${result.tag})\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;
}
}

View file

@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it } from 'node:test';
import { updatePublicReleaseVersion } from './update-public-release-version.mjs';
async function writeJson(path, value) {
await mkdir(join(path, '..'), { recursive: true });
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
}
async function readJson(path) {
return JSON.parse(await readFile(path, 'utf8'));
}
async function writeReleaseFixture(root) {
await writeJson(join(root, 'package.json'), {
name: 'ktx-workspace',
version: '0.0.0-private',
private: true,
});
await writeJson(join(root, 'release-policy.json'), {
schemaVersion: 1,
publicNpmPackageVersion: '0.1.0-rc.1',
releaseMode: 'ci-artifact-only',
npm: {
publish: false,
registry: null,
access: 'public',
tag: 'next',
packages: ['@kaelio/ktx'],
},
python: {
publish: false,
repository: null,
packages: ['kaelio-ktx'],
},
publishedPackageSmoke: {
packageName: '@kaelio/ktx',
version: '0.1.0-rc.1',
registry: null,
},
runtimeInstaller: {
uvStrategy: 'path-prerequisite',
bootstrapUv: false,
missingUvBehavior: 'focused-error',
},
requiredBeforePublishing: ['Choose public release version.'],
});
}
describe('updatePublicReleaseVersion', () => {
it('updates package and release policy metadata for rc releases', async () => {
const root = await mkdtemp(join(tmpdir(), 'ktx-release-version-test-'));
try {
await writeReleaseFixture(root);
await updatePublicReleaseVersion(root, '0.1.0-rc.2', 'next');
assert.equal((await readJson(join(root, 'package.json'))).version, '0.1.0-rc.2');
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,
registry: null,
access: 'public',
tag: 'next',
packages: ['@kaelio/ktx'],
},
python: {
publish: false,
repository: null,
packages: ['kaelio-ktx'],
},
publishedPackageSmoke: {
packageName: '@kaelio/ktx',
version: '0.1.0-rc.2',
registry: null,
},
runtimeInstaller: {
uvStrategy: 'path-prerequisite',
bootstrapUv: false,
missingUvBehavior: 'focused-error',
},
requiredBeforePublishing: [],
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
it('rejects invalid versions and tags', async () => {
const root = await mkdtemp(join(tmpdir(), 'ktx-release-version-invalid-test-'));
try {
await writeReleaseFixture(root);
await assert.rejects(() => updatePublicReleaseVersion(root, 'not a version', 'next'), /Invalid public npm package version/);
await assert.rejects(() => updatePublicReleaseVersion(root, '0.2.0', 'canary'), /Invalid public npm release tag/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
});