diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 95fdf9d4..d3454bbb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,6 +69,20 @@ 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: | diff --git a/docs/release.md b/docs/release.md index 5e8a254b..acfb2b71 100644 --- a/docs/release.md +++ b/docs/release.md @@ -29,11 +29,18 @@ Before you publish, confirm these requirements: `.github/workflows/release.yml` workflow. - The workflow keeps `id-token: write` permission so npm can verify the GitHub Actions run through OpenID Connect. -- The repository has a baseline semantic-release tag for the latest published - package version, such as `v0.1.0-rc.1`. +- 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`. -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. +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`. + +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. ## Dry-run a release diff --git a/scripts/package-artifacts.mjs b/scripts/package-artifacts.mjs index 8e1f174d..a3d1133f 100644 --- a/scripts/package-artifacts.mjs +++ b/scripts/package-artifacts.mjs @@ -481,13 +481,15 @@ export function npmRuntimeSmokeSource() { return ` import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; -import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); +const require = createRequire(import.meta.url); async function run(command, args, options = {}) { process.stdout.write('$ ' + command + ' ' + args.join(' ') + '\\n'); @@ -547,6 +549,15 @@ function requireOutput(label, result, text) { assert.match(result.stdout, text, label + ' output did not match ' + text); } +function escapeRegExp(value) { + return value.replace(/[|\\\\{}()[\\]^$+*?.]/g, '\\\\$&'); +} + +async function installedPackageVersionPattern() { + const packageJson = JSON.parse(await readFile(require.resolve('@kaelio/ktx/package.json'), 'utf8')); + return new RegExp('^' + escapeRegExp(packageJson.name) + ' ' + escapeRegExp(packageJson.version) + '$', 'm'); +} + function parseJsonResult(label, result) { requireSuccess(label, result); return JSON.parse(result.stdout); @@ -592,7 +603,7 @@ try { const version = await run('pnpm', ['exec', 'ktx', '--version']); requireSuccess('ktx public package version', version); - requireOutput('ktx public package version', version, /@kaelio\\/ktx 0\\.1\\.0/); + requireOutput('ktx public package version', version, await installedPackageVersionPattern()); const runtimeStatusBefore = parseJsonResultWithExitCode( 'ktx dev runtime status missing', diff --git a/scripts/package-artifacts.test.mjs b/scripts/package-artifacts.test.mjs index bbffe06c..89d1a760 100644 --- a/scripts/package-artifacts.test.mjs +++ b/scripts/package-artifacts.test.mjs @@ -467,7 +467,8 @@ describe('verification snippets', () => { const source = npmRuntimeSmokeSource(); assert.match(source, /ktx public package version/); - assert.match(source, /@kaelio\\\/ktx 0\\\.1\\\.0/); + assert.match(source, /installedPackageVersionPattern/); + assert.doesNotMatch(source, /@kaelio\\\/ktx 0\\\.1\\\.0/); assert.match(source, /'ktx', 'sl', 'query'/); assert.doesNotMatch(source, /@ktx\/context/); assert.doesNotMatch(source, /@modelcontextprotocol/); diff --git a/scripts/release-workflow.test.mjs b/scripts/release-workflow.test.mjs index 60a82011..0611e3fb 100644 --- a/scripts/release-workflow.test.mjs +++ b/scripts/release-workflow.test.mjs @@ -17,6 +17,9 @@ describe('release workflow', () => { 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.match(workflow, /Prepare next prerelease branch/); assert.match(workflow, /git checkout -B "\$\{KTX_PRERELEASE_BRANCH\}"/); assert.match(workflow, /GITHUB_REF="refs\/heads\/\$\{KTX_PRERELEASE_BRANCH\}"/); diff --git a/scripts/semantic-release-config.cjs b/scripts/semantic-release-config.cjs index 66b9aad7..347cf5f6 100644 --- a/scripts/semantic-release-config.cjs +++ b/scripts/semantic-release-config.cjs @@ -137,6 +137,7 @@ function createReleaseConfig(env = process.env) { }, }, ], + './scripts/semantic-release-version-policy.cjs', '@semantic-release/changelog', [ '@semantic-release/exec', diff --git a/scripts/semantic-release-config.test.mjs b/scripts/semantic-release-config.test.mjs index 0a267d77..d52fbd7a 100644 --- a/scripts/semantic-release-config.test.mjs +++ b/scripts/semantic-release-config.test.mjs @@ -35,6 +35,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')); }); it('rejects stable releases from non-main branches', () => { @@ -51,4 +52,16 @@ describe('semantic-release config', () => { ); assert.match(analyzeExec[1].analyzeCommitsCmd, /FORCE_RELEASE === 'true' \? 'patch' : ''/); }); + + it('does not configure any commit type to create an automatic major release', () => { + const config = createReleaseConfig({ KTX_RELEASE_KIND: 'stable', GITHUB_REF_NAME: 'main' }); + const analyzer = config.plugins.find( + (plugin) => Array.isArray(plugin) && plugin[0] === '@semantic-release/commit-analyzer', + ); + + assert.equal( + analyzer[1].releaseRules.some((rule) => rule.release === 'major'), + false, + ); + }); }); diff --git a/scripts/semantic-release-version-policy.cjs b/scripts/semantic-release-version-policy.cjs new file mode 100644 index 00000000..ca65b5b6 --- /dev/null +++ b/scripts/semantic-release-version-policy.cjs @@ -0,0 +1,114 @@ +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 new file mode 100644 index 00000000..cc4c7cec --- /dev/null +++ b/scripts/semantic-release-version-policy.test.mjs @@ -0,0 +1,123 @@ +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 }); + } + }); +});