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.
This commit is contained in:
Andrey Avtomonov 2026-05-20 16:47:03 +02:00
parent 2667952aa9
commit c859a22fda
8 changed files with 191 additions and 13 deletions

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