fix: publish first stable release as 0.1.0 (#143)

This commit is contained in:
Andrey Avtomonov 2026-05-19 15:52:30 +02:00 committed by GitHub
parent b4c77c0563
commit 7110aa6f5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 294 additions and 7 deletions

View file

@ -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: |

View file

@ -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

View file

@ -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',

View file

@ -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/);

View file

@ -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\}"/);

View file

@ -137,6 +137,7 @@ function createReleaseConfig(env = process.env) {
},
},
],
'./scripts/semantic-release-version-policy.cjs',
'@semantic-release/changelog',
[
'@semantic-release/exec',

View file

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

View file

@ -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,
};

View file

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