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

@ -7,10 +7,10 @@ on:
description: "Release kind: rc publishes to next, stable publishes to latest"
required: true
type: choice
default: "rc"
default: "stable"
options:
- rc
- stable
- rc
force_release:
description: "Force a patch release even if semantic-release finds no releasable commits"
required: false
@ -20,7 +20,7 @@ on:
description: "Create the release and publish @kaelio/ktx to npm instead of running a dry-run"
required: true
type: boolean
default: false
default: true
permissions:
contents: write
@ -69,20 +69,6 @@ jobs:
- name: Install Python dependencies
run: uv sync --all-packages
- name: Prepare first stable release floor
if: ${{ inputs.release_kind == 'stable' }}
run: |
set -euo pipefail
stable_tags="$(git tag --merged HEAD --list 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '-' || true)"
if [ -n "${stable_tags}" ]; then
exit 0
fi
root_commit="$(git rev-list --max-parents=0 HEAD | tail -n 1)"
git tag v0.0.0 "${root_commit}"
echo "KTX_STABLE_RELEASE_FLOOR_TAG=v0.0.0" >> "${GITHUB_ENV}"
- name: Prepare next prerelease branch
if: ${{ inputs.release_kind == 'rc' }}
run: |
@ -104,6 +90,26 @@ jobs:
env:
KTX_PRERELEASE_BRANCH: next
- name: Prepare npm package root for release verification
run: |
set -euo pipefail
mkdir -p dist/public-npm-package
node --input-type=module <<'EOF'
import { writeFile } from 'node:fs/promises';
const packageJson = {
name: '@kaelio/ktx',
version: '0.0.0',
private: false
};
await writeFile(
'dist/public-npm-package/package.json',
`${JSON.stringify(packageJson, null, 2)}\n`
);
EOF
- name: Dry-run semantic release
if: ${{ !inputs.publish_live }}
run: |

View file

@ -15,7 +15,9 @@ KTX has two npm release channels:
Run rc releases from the source branch you want to publish. The workflow
creates or updates the `next` prerelease branch from that source branch before
running semantic-release, because semantic-release requires a dedicated
prerelease branch in addition to the stable `main` branch.
prerelease branch in addition to the stable `main` branch. You can publish an
rc from `main` when you want to validate the current stable branch before a
stable release.
Run stable releases only from `main`. The workflow rejects stable releases from
other branches.
@ -31,16 +33,24 @@ Before you publish, confirm these requirements:
GitHub Actions run through OpenID Connect.
- The repository has release metadata in `release-policy.json` for the current
public package line, such as `0.1.0-rc.1` or `0.1.0`.
- The repository has a stable baseline tag when you need semantic-release to
publish the first stable version as `0.1.0`.
If no stable baseline tag exists, semantic-release treats the stable run as the
first release. KTX seeds that first stable release from the base version in
`release-policy.json`, so `0.1.0-rc.6` promotes to `0.1.0` instead of
semantic-release's default `1.0.0`.
semantic-release doesn't support choosing an arbitrary first `0.x` stable
release. If KTX has no stable tag yet and you need the first stable release to
be `0.1.0`, create and push the baseline tag once before running the live
stable workflow:
KTX blocks automatic major releases. A major version requires an intentional
manual release path that updates release metadata and creates the intended
version explicitly; don't rely on semantic-release commit analysis for major
bumps.
```bash
root_commit="$(git rev-list --max-parents=0 HEAD | tail -n 1)"
git tag v0.0.0 "${root_commit}"
git push origin v0.0.0
```
KTX follows the same versioning schema as the main Kaelio release workflow:
breaking-change and `major` commit markers create a minor release, not an
automatic major release. A major version requires an intentional manual release
path.
## Dry-run a release
@ -51,7 +61,7 @@ publishing to npm.
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`.
5. Set **publish_live** 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.
@ -67,9 +77,9 @@ promoting to `latest`.
1. Open **Actions** in GitHub.
2. Select **KTX Release**.
3. Select the source branch to release from.
3. Select the source branch to release from, including `main` when needed.
4. Set **release_kind** to `rc`.
5. Set **publish_live** to `true`.
5. Leave **publish_live** set to `true`.
6. Optional: Set **force_release** to `true`.
7. Run the workflow.
@ -85,8 +95,8 @@ 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`.
4. Leave **release_kind** set to `stable`.
5. Leave **publish_live** set to `true`.
6. Optional: Set **force_release** to `true`.
7. Run the workflow.
@ -97,7 +107,8 @@ release metadata.
## Release metadata
semantic-release calls `scripts/update-public-release-version.mjs` during the
prepare step. That script updates:
prepare step before `@semantic-release/npm` publishes the package. That script
updates:
- `package.json` with the semantic-release version.
- `release-policy.json` with `publicNpmPackageVersion`, npm publish settings,
@ -105,7 +116,9 @@ prepare step. That script updates:
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.
needed for rc releases. The semantic-release npm plugin publishes the generated
`dist/public-npm-package` tree and writes the release tarball under
`dist/artifacts/npm`.
The bundled Python runtime wheel also derives its version from
`publicNpmPackageVersion`. Stable npm versions are reused as-is, and rc

View file

@ -7,6 +7,7 @@
"ignoreDependencies": [
"@semantic-release/commit-analyzer",
"@semantic-release/github",
"@semantic-release/npm",
"@semantic-release/release-notes-generator",
"conventional-changelog-conventionalcommits"
]

View file

@ -29,7 +29,6 @@
"setup:dev": "node scripts/setup-dev.mjs",
"release:published-smoke": "node scripts/published-package-smoke.mjs --require-config",
"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",
@ -54,6 +53,7 @@
"@semantic-release/exec": "^7.1.0",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.8",
"@semantic-release/npm": "^13.1.5",
"@semantic-release/release-notes-generator": "^14.1.1",
"@types/node": "^25.7.0",
"better-sqlite3": "^12.10.0",

3
pnpm-lock.yaml generated
View file

@ -30,6 +30,9 @@ importers:
'@semantic-release/github':
specifier: ^12.0.8
version: 12.0.8(semantic-release@25.0.3(typescript@6.0.3))
'@semantic-release/npm':
specifier: ^13.1.5
version: 13.1.5(semantic-release@25.0.3(typescript@6.0.3))
'@semantic-release/release-notes-generator':
specifier: ^14.1.1
version: 14.1.1(semantic-release@25.0.3(typescript@6.0.3))

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 });
}
});
});