From bbd9568287f588424418e8b5e710111cd71071af Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 19 May 2026 16:33:41 +0200 Subject: [PATCH] ci: simplify ktx release flow --- .github/workflows/release.yml | 40 +++--- docs/release.md | 45 ++++--- knip.json | 1 + package.json | 2 +- pnpm-lock.yaml | 3 + scripts/check-boundaries.mjs | 2 +- scripts/check-boundaries.test.mjs | 1 - scripts/publish-public-npm-package.mjs | 118 ----------------- scripts/publish-public-npm-package.test.mjs | 108 --------------- scripts/release-workflow.test.mjs | 13 +- scripts/semantic-release-config.cjs | 18 ++- scripts/semantic-release-config.test.mjs | 20 ++- scripts/semantic-release-version-policy.cjs | 114 ---------------- .../semantic-release-version-policy.test.mjs | 123 ------------------ 14 files changed, 98 insertions(+), 510 deletions(-) delete mode 100644 scripts/publish-public-npm-package.mjs delete mode 100644 scripts/publish-public-npm-package.test.mjs delete mode 100644 scripts/semantic-release-version-policy.cjs delete mode 100644 scripts/semantic-release-version-policy.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d3454bbb..d00a8fdd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: | diff --git a/docs/release.md b/docs/release.md index acfb2b71..833063d8 100644 --- a/docs/release.md +++ b/docs/release.md @@ -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 diff --git a/knip.json b/knip.json index 2fd48187..51459946 100644 --- a/knip.json +++ b/knip.json @@ -7,6 +7,7 @@ "ignoreDependencies": [ "@semantic-release/commit-analyzer", "@semantic-release/github", + "@semantic-release/npm", "@semantic-release/release-notes-generator", "conventional-changelog-conventionalcommits" ] diff --git a/package.json b/package.json index ad773b28..ae1fb151 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ed856aa..5dbc325a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)) diff --git a/scripts/check-boundaries.mjs b/scripts/check-boundaries.mjs index f51fd223..3093fd42 100644 --- a/scripts/check-boundaries.mjs +++ b/scripts/check-boundaries.mjs @@ -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_']; diff --git a/scripts/check-boundaries.test.mjs b/scripts/check-boundaries.test.mjs index 952b5614..832dc1c2 100644 --- a/scripts/check-boundaries.test.mjs +++ b/scripts/check-boundaries.test.mjs @@ -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); diff --git a/scripts/publish-public-npm-package.mjs b/scripts/publish-public-npm-package.mjs deleted file mode 100644 index c8c5b6e3..00000000 --- a/scripts/publish-public-npm-package.mjs +++ /dev/null @@ -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; - } -} diff --git a/scripts/publish-public-npm-package.test.mjs b/scripts/publish-public-npm-package.test.mjs deleted file mode 100644 index d3b5b016..00000000 --- a/scripts/publish-public-npm-package.test.mjs +++ /dev/null @@ -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'); - }); -}); diff --git a/scripts/release-workflow.test.mjs b/scripts/release-workflow.test.mjs index 0611e3fb..e6f8350a 100644 --- a/scripts/release-workflow.test.mjs +++ b/scripts/release-workflow.test.mjs @@ -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); diff --git a/scripts/semantic-release-config.cjs b/scripts/semantic-release-config.cjs index 347cf5f6..dfc837cc 100644 --- a/scripts/semantic-release-config.cjs +++ b/scripts/semantic-release-config.cjs @@ -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', }, ], [ diff --git a/scripts/semantic-release-config.test.mjs b/scripts/semantic-release-config.test.mjs index d52fbd7a..109f114a 100644 --- a/scripts/semantic-release-config.test.mjs +++ b/scripts/semantic-release-config.test.mjs @@ -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' }, + ], + ); }); }); diff --git a/scripts/semantic-release-version-policy.cjs b/scripts/semantic-release-version-policy.cjs deleted file mode 100644 index ca65b5b6..00000000 --- a/scripts/semantic-release-version-policy.cjs +++ /dev/null @@ -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, -}; diff --git a/scripts/semantic-release-version-policy.test.mjs b/scripts/semantic-release-version-policy.test.mjs deleted file mode 100644 index cc4c7cec..00000000 --- a/scripts/semantic-release-version-policy.test.mjs +++ /dev/null @@ -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 }); - } - }); -});