feat(release): one version everywhere via @semantic-release/git (#186)

* feat(release): commit version files back to branch for one-version-everywhere

Add @semantic-release/git to the release plugin chain so the bumped
package.json, release-policy.json, and packages/cli/package.json land
back on the release branch after publish. This keeps the published npm
version and the in-repo version files in sync, so local builds from
main report the released version (e.g. ktx --version and the daemon
/health endpoint via KTX_DAEMON_VERSION).

Also widens assertPublicNpmReleaseTag to accept branch-<sanitized> tags,
unblocking branch RC publishes that pass through update-public-release-
version.mjs.

* test(release): pin GITHUB_REF_NAME in main-rc releaseTag assertion

The bare releaseTag('rc') call defaulted to process.env.GITHUB_REF_NAME,
which on PR CI is the merge ref (e.g. 186/merge) and yields
'branch-186-merge' instead of 'next'. Pass an explicit { GITHUB_REF_NAME:
'main' } so the test exercises the main-rc path regardless of CI env.
This commit is contained in:
Andrey Avtomonov 2026-05-20 17:01:26 +02:00 committed by GitHub
parent 2667952aa9
commit 2f70861a18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 192 additions and 14 deletions

View file

@ -50,6 +50,7 @@
"@biomejs/biome": "^2.4.15",
"@semantic-release/commit-analyzer": "^13.0.1",
"@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",

76
pnpm-lock.yaml generated
View file

@ -28,6 +28,9 @@ importers:
'@semantic-release/exec':
specifier: ^7.1.0
version: 7.1.0(semantic-release@25.0.3(typescript@6.0.3))
'@semantic-release/git':
specifier: ^10.0.1
version: 10.0.1(semantic-release@25.0.3(typescript@6.0.3))
'@semantic-release/github':
specifier: ^12.0.8
version: 12.0.8(semantic-release@25.0.3(typescript@6.0.3))
@ -2333,6 +2336,10 @@ packages:
peerDependencies:
semantic-release: '>=20.1.0'
'@semantic-release/error@3.0.0':
resolution: {integrity: sha512-5hiM4Un+tpl4cKw3lV4UgzJj+SmfNIDCLLw0TepzQxz9ZGV5ixnqkzIVF+3tp0ZHgcMKE+VNGHJjEeyFG2dcSw==}
engines: {node: '>=14.17'}
'@semantic-release/error@4.0.0':
resolution: {integrity: sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==}
engines: {node: '>=18'}
@ -2343,6 +2350,12 @@ packages:
peerDependencies:
semantic-release: '>=24.1.0'
'@semantic-release/git@10.0.1':
resolution: {integrity: sha512-eWrx5KguUcU2wUPaO6sfvZI0wPafUKAMNC18aXY4EnNcrZL86dEmpNVnC9uMpGZkmZJ9EfCVJBQx4pV4EMGT1w==}
engines: {node: '>=14.17'}
peerDependencies:
semantic-release: '>=18.0.0'
'@semantic-release/github@12.0.8':
resolution: {integrity: sha512-tej5AAgK5X9wHRoDmYhecMXEHEkFeGOY1XsEblKxu8pIQwahzf1STYyr7iPU6Lpbg6C5I3N2w/ocXrBo+L7jhw==}
engines: {node: ^22.14.0 || >= 24.10.0}
@ -3608,6 +3621,10 @@ packages:
resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
engines: {node: '>=18.0.0'}
execa@5.1.1:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
execa@8.0.1:
resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==}
engines: {node: '>=16.17'}
@ -4087,6 +4104,10 @@ packages:
resolution: {integrity: sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==}
engines: {node: '>= 20'}
human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
human-signals@5.0.0:
resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
engines: {node: '>=16.17.0'}
@ -4467,6 +4488,9 @@ packages:
lodash.uniqby@4.7.0:
resolution: {integrity: sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==}
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
logform@2.7.0:
resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==}
engines: {node: '>= 12.0.0'}
@ -4883,6 +4907,10 @@ packages:
resolution: {integrity: sha512-z9nC87iaZXXySbWWtTHfCFJyFvKaUAW6lODhikG7ILSbVgmwuFjUqkgnheHvAUcGedO29e2QGBRXMUD64aurqQ==}
engines: {node: '>=20'}
npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
npm-run-path@5.3.0:
resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@ -5055,6 +5083,10 @@ packages:
resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==}
engines: {node: '>=18'}
p-reduce@2.1.0:
resolution: {integrity: sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw==}
engines: {node: '>=8'}
p-reduce@3.0.0:
resolution: {integrity: sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==}
engines: {node: '>=12'}
@ -5669,6 +5701,10 @@ packages:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
strip-final-newline@2.0.0:
resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==}
engines: {node: '>=6'}
strip-final-newline@3.0.0:
resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==}
engines: {node: '>=12'}
@ -8530,6 +8566,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@semantic-release/error@3.0.0': {}
'@semantic-release/error@4.0.0': {}
'@semantic-release/exec@7.1.0(semantic-release@25.0.3(typescript@6.0.3))':
@ -8544,6 +8582,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@semantic-release/git@10.0.1(semantic-release@25.0.3(typescript@6.0.3))':
dependencies:
'@semantic-release/error': 3.0.0
aggregate-error: 3.1.0
debug: 4.4.3
dir-glob: 3.0.1
execa: 5.1.1
lodash: 4.18.1
micromatch: 4.0.8
p-reduce: 2.1.0
semantic-release: 25.0.3(typescript@6.0.3)
transitivePeerDependencies:
- supports-color
'@semantic-release/github@12.0.8(semantic-release@25.0.3(typescript@6.0.3))':
dependencies:
'@octokit/core': 7.0.6
@ -9968,6 +10020,18 @@ snapshots:
dependencies:
eventsource-parser: 3.0.8
execa@5.1.1:
dependencies:
cross-spawn: 7.0.6
get-stream: 6.0.1
human-signals: 2.1.0
is-stream: 2.0.1
merge-stream: 2.0.0
npm-run-path: 4.0.1
onetime: 5.1.2
signal-exit: 3.0.7
strip-final-newline: 2.0.0
execa@8.0.1:
dependencies:
cross-spawn: 7.0.6
@ -10564,6 +10628,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
human-signals@2.1.0: {}
human-signals@5.0.0: {}
human-signals@8.0.1: {}
@ -10903,6 +10969,8 @@ snapshots:
lodash.uniqby@4.7.0: {}
lodash@4.18.1: {}
logform@2.7.0:
dependencies:
'@colors/colors': 1.6.0
@ -11589,6 +11657,10 @@ snapshots:
normalize-url@9.0.0: {}
npm-run-path@4.0.1:
dependencies:
path-key: 3.1.1
npm-run-path@5.3.0:
dependencies:
path-key: 4.0.0
@ -11732,6 +11804,8 @@ snapshots:
p-map@7.0.4: {}
p-reduce@2.1.0: {}
p-reduce@3.0.0: {}
p-timeout@6.1.4: {}
@ -12525,6 +12599,8 @@ snapshots:
strip-bom@3.0.0: {}
strip-final-newline@2.0.0: {}
strip-final-newline@3.0.0: {}
strip-final-newline@4.0.0: {}

View file

@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
export const PUBLIC_NPM_PACKAGE_NAME = '@kaelio/ktx';
export const PUBLIC_NPM_RELEASE_TAGS = new Set(['latest', 'next']);
export const PUBLIC_NPM_BRANCH_RELEASE_TAG_PATTERN = /^branch-[a-z0-9]+(?:-[a-z0-9]+)*$/;
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-]+)*)?$/;
@ -56,10 +57,13 @@ export function publicNpmPackageVersionToPythonVersion(version) {
}
export function assertPublicNpmReleaseTag(tag) {
if (!PUBLIC_NPM_RELEASE_TAGS.has(tag)) {
if (typeof tag !== 'string') {
throw new Error(`Invalid public npm release tag: ${tag}`);
}
return tag;
if (PUBLIC_NPM_RELEASE_TAGS.has(tag) || PUBLIC_NPM_BRANCH_RELEASE_TAG_PATTERN.test(tag)) {
return tag;
}
throw new Error(`Invalid public npm release tag: ${tag}`);
}
export function readPublicNpmReleaseMetadata(rootDir = scriptRootDir()) {

View file

@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { publicNpmPackageVersionToPythonVersion } from './public-npm-release-metadata.mjs';
import { assertPublicNpmReleaseTag, publicNpmPackageVersionToPythonVersion } from './public-npm-release-metadata.mjs';
describe('publicNpmPackageVersionToPythonVersion', () => {
it('keeps stable public npm versions unchanged for Python wheels', () => {
@ -24,3 +24,22 @@ describe('publicNpmPackageVersionToPythonVersion', () => {
);
});
});
describe('assertPublicNpmReleaseTag', () => {
it('accepts the canonical latest and next tags', () => {
assert.equal(assertPublicNpmReleaseTag('latest'), 'latest');
assert.equal(assertPublicNpmReleaseTag('next'), 'next');
});
it('accepts branch-prefixed release tags produced by branch RC publishes', () => {
assert.equal(assertPublicNpmReleaseTag('branch-feature-foo'), 'branch-feature-foo');
assert.equal(assertPublicNpmReleaseTag('branch-rel-1-2-3'), 'branch-rel-1-2-3');
assert.equal(assertPublicNpmReleaseTag('branch-x'), 'branch-x');
});
it('rejects malformed or non-string tags', () => {
for (const bad of ['', 'BRANCH-x', 'branch-', 'branch--foo', 'branch_foo', 'beta', null, undefined, 42]) {
assert.throws(() => assertPublicNpmReleaseTag(bad), /Invalid public npm release tag/);
}
});
});

View file

@ -161,6 +161,18 @@ function createReleaseConfig(env = process.env) {
'pnpm run artifacts:check',
'pnpm run release:readiness',
].join(' && '),
},
],
[
'@semantic-release/git',
{
assets: ['package.json', 'release-policy.json', 'packages/cli/package.json'],
message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
},
],
[
'@semantic-release/exec',
{
publishCmd: [
`npm publish dist/artifacts/npm/kaelio-ktx-\${nextRelease.version}.tgz --tag ${tag} --access public --provenance`,
'pnpm run release:published-smoke',

View file

@ -5,10 +5,19 @@ import { describe, it } from 'node:test';
const require = createRequire(import.meta.url);
const { createReleaseConfig, releaseBranches, releaseKind, releaseTag } = require('./semantic-release-config.cjs');
function releaseExecOptions(config) {
function prepareExecOptions(config) {
return config.plugins.find((plugin) => Array.isArray(plugin) && plugin[0] === '@semantic-release/exec' && plugin[1].prepareCmd)[1];
}
function publishExecOptions(config) {
return config.plugins.find((plugin) => Array.isArray(plugin) && plugin[0] === '@semantic-release/exec' && plugin[1].publishCmd)[1];
}
function gitPluginOptions(config) {
const found = config.plugins.find((plugin) => Array.isArray(plugin) && plugin[0] === '@semantic-release/git');
return found ? found[1] : undefined;
}
function pluginNames(config) {
return config.plugins.map((plugin) => (Array.isArray(plugin) ? plugin[0] : plugin));
}
@ -16,7 +25,7 @@ function pluginNames(config) {
describe('semantic-release config', () => {
it('configures rc releases as a prerelease on main', () => {
assert.equal(releaseKind({ KTX_RELEASE_KIND: 'rc' }), 'rc');
assert.equal(releaseTag('rc'), 'next');
assert.equal(releaseTag('rc', { GITHUB_REF_NAME: 'main' }), 'next');
assert.deepEqual(releaseBranches({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'main' }), [
{ name: 'main', prerelease: 'rc', channel: 'next' },
]);
@ -28,14 +37,14 @@ describe('semantic-release config', () => {
'@semantic-release/npm must not run; the exec publishCmd publishes the pre-built tarball',
);
assert.match(
releaseExecOptions(config).prepareCmd,
prepareExecOptions(config).prepareCmd,
/update-public-release-version\.mjs "\$\{nextRelease\.version\}" "next"/,
);
assert.match(
releaseExecOptions(config).publishCmd,
publishExecOptions(config).publishCmd,
/^npm publish dist\/artifacts\/npm\/kaelio-ktx-\$\{nextRelease\.version\}\.tgz --tag next --access public --provenance/,
);
assert.match(releaseExecOptions(config).publishCmd, /pnpm run release:published-smoke/);
assert.match(publishExecOptions(config).publishCmd, /pnpm run release:published-smoke/);
assert.doesNotMatch(JSON.stringify(config.plugins), /release:npm-publish/);
});
@ -48,11 +57,11 @@ describe('semantic-release config', () => {
const config = createReleaseConfig({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'feature/branch-release' });
assert.match(
releaseExecOptions(config).prepareCmd,
prepareExecOptions(config).prepareCmd,
/update-public-release-version\.mjs "\$\{nextRelease\.version\}" "branch-feature-branch-release"/,
);
assert.match(
releaseExecOptions(config).publishCmd,
publishExecOptions(config).publishCmd,
/^npm publish dist\/artifacts\/npm\/kaelio-ktx-\$\{nextRelease\.version\}\.tgz --tag branch-feature-branch-release --access public --provenance/,
);
});
@ -64,24 +73,50 @@ describe('semantic-release config', () => {
const config = createReleaseConfig({ KTX_RELEASE_KIND: 'stable', GITHUB_REF_NAME: 'main' });
assert.match(
releaseExecOptions(config).prepareCmd,
prepareExecOptions(config).prepareCmd,
/update-public-release-version\.mjs "\$\{nextRelease\.version\}" "latest"/,
);
assert.match(
releaseExecOptions(config).publishCmd,
publishExecOptions(config).publishCmd,
/^npm publish dist\/artifacts\/npm\/kaelio-ktx-\$\{nextRelease\.version\}\.tgz --tag latest --access public --provenance/,
);
assert.equal(config.plugins.includes('./scripts/semantic-release-version-policy.cjs'), false);
});
it('never commits release files back to the repo', () => {
it('commits release version files back to the branch via @semantic-release/git', () => {
for (const kind of ['rc', 'stable']) {
const config = createReleaseConfig({ KTX_RELEASE_KIND: kind, GITHUB_REF_NAME: 'main' });
assert.equal(pluginNames(config).includes('@semantic-release/git'), false, `${kind}: @semantic-release/git`);
const git = gitPluginOptions(config);
assert.ok(git, `${kind}: @semantic-release/git plugin must be configured`);
assert.deepEqual(git.assets, ['package.json', 'release-policy.json', 'packages/cli/package.json']);
assert.match(git.message, /^chore\(release\): \$\{nextRelease\.version\} \[skip ci\]/);
}
});
it('keeps @semantic-release/npm and @semantic-release/changelog out of the plugin chain', () => {
for (const kind of ['rc', 'stable']) {
const config = createReleaseConfig({ KTX_RELEASE_KIND: kind, GITHUB_REF_NAME: 'main' });
assert.equal(pluginNames(config).includes('@semantic-release/npm'), false, `${kind}: @semantic-release/npm`);
assert.equal(pluginNames(config).includes('@semantic-release/changelog'), false, `${kind}: @semantic-release/changelog`);
}
});
it('orders the prepare exec before @semantic-release/git before the publish exec', () => {
const config = createReleaseConfig({ KTX_RELEASE_KIND: 'stable', GITHUB_REF_NAME: 'main' });
const prepareIndex = config.plugins.findIndex(
(plugin) => Array.isArray(plugin) && plugin[0] === '@semantic-release/exec' && plugin[1].prepareCmd,
);
const gitIndex = config.plugins.findIndex(
(plugin) => Array.isArray(plugin) && plugin[0] === '@semantic-release/git',
);
const publishIndex = config.plugins.findIndex(
(plugin) => Array.isArray(plugin) && plugin[0] === '@semantic-release/exec' && plugin[1].publishCmd,
);
assert.ok(prepareIndex !== -1 && gitIndex !== -1 && publishIndex !== -1);
assert.ok(prepareIndex < gitIndex, 'prepare exec must run before @semantic-release/git');
assert.ok(gitIndex < publishIndex, '@semantic-release/git must run before the publish exec');
});
it('produces a loadable config regardless of GITHUB_REF_NAME', () => {
// Knip and other tooling load .releaserc.cjs on PR runners where
// GITHUB_REF_NAME is the merge ref. semantic-release itself enforces the

View file

@ -32,6 +32,11 @@ export async function updatePublicReleaseVersion(rootDir, version, tag) {
packageJson.version = safeVersion;
await writeJson(packageJsonPath, packageJson);
const cliPackageJsonPath = join(rootDir, 'packages', 'cli', 'package.json');
const cliPackageJson = await readJson(cliPackageJsonPath);
cliPackageJson.version = safeVersion;
await writeJson(cliPackageJsonPath, cliPackageJson);
const policyPath = releasePolicyPath(rootDir);
const policy = await readJson(policyPath);
policy.publicNpmPackageVersion = safeVersion;

View file

@ -21,6 +21,11 @@ async function writeReleaseFixture(root) {
version: '0.0.0-private',
private: true,
});
await writeJson(join(root, 'packages', 'cli', 'package.json'), {
name: '@ktx/cli',
version: '0.0.0-private',
private: true,
});
await writeJson(join(root, 'release-policy.json'), {
schemaVersion: 1,
publicNpmPackageVersion: '0.1.0-rc.1',
@ -60,6 +65,7 @@ describe('updatePublicReleaseVersion', () => {
await updatePublicReleaseVersion(root, '0.1.0-rc.2', 'next');
assert.equal((await readJson(join(root, 'package.json'))).version, '0.1.0-rc.2');
assert.equal((await readJson(join(root, 'packages', 'cli', 'package.json'))).version, '0.1.0-rc.2');
assert.deepEqual(await readJson(join(root, 'release-policy.json')), {
schemaVersion: 1,
publicNpmPackageVersion: '0.1.0-rc.2',
@ -93,6 +99,26 @@ describe('updatePublicReleaseVersion', () => {
}
});
it('accepts branch-prefixed npm release tags produced by branch RC publishes', async () => {
const root = await mkdtemp(join(tmpdir(), 'ktx-release-version-branch-test-'));
try {
await writeReleaseFixture(root);
await updatePublicReleaseVersion(root, '0.1.0-feature-foo.0', 'branch-feature-foo');
assert.equal((await readJson(join(root, 'package.json'))).version, '0.1.0-feature-foo.0');
assert.equal(
(await readJson(join(root, 'packages', 'cli', 'package.json'))).version,
'0.1.0-feature-foo.0',
);
const policy = await readJson(join(root, 'release-policy.json'));
assert.equal(policy.publicNpmPackageVersion, '0.1.0-feature-foo.0');
assert.equal(policy.npm.tag, 'branch-feature-foo');
} finally {
await rm(root, { recursive: true, force: true });
}
});
it('rejects invalid versions and tags', async () => {
const root = await mkdtemp(join(tmpdir(), 'ktx-release-version-invalid-test-'));
try {