mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
fix: publish first stable release as 0.1.0 (#143)
This commit is contained in:
parent
b4c77c0563
commit
7110aa6f5c
9 changed files with 294 additions and 7 deletions
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
|
|
@ -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\}"/);
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ function createReleaseConfig(env = process.env) {
|
|||
},
|
||||
},
|
||||
],
|
||||
'./scripts/semantic-release-version-policy.cjs',
|
||||
'@semantic-release/changelog',
|
||||
[
|
||||
'@semantic-release/exec',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
114
scripts/semantic-release-version-policy.cjs
Normal file
114
scripts/semantic-release-version-policy.cjs
Normal 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,
|
||||
};
|
||||
123
scripts/semantic-release-version-policy.test.mjs
Normal file
123
scripts/semantic-release-version-policy.test.mjs
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue