From 03e2f9f0a35a4df3144be3a95368ed8c90e8f55d Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 19 May 2026 16:01:07 +0200 Subject: [PATCH] fix: prevent stable release pushes to main (#145) --- docs/release.md | 10 ++++---- scripts/semantic-release-config.cjs | 30 +++++++++++++++++------- scripts/semantic-release-config.test.mjs | 23 ++++++++++++++++++ 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/docs/release.md b/docs/release.md index acfb2b71..7678232f 100644 --- a/docs/release.md +++ b/docs/release.md @@ -3,7 +3,7 @@ This runbook covers the maintainer workflow for publishing `@kaelio/ktx` to npm through GitHub Actions. The workflow uses semantic-release to choose the next version, update release metadata, publish the package, create the GitHub -release, and commit the release files back to the repository. +release, and commit prerelease files back to the `next` branch. ## Release channels @@ -91,8 +91,9 @@ Publish a stable release from `main` after you have validated an rc package. 7. Run the workflow. The workflow publishes `@kaelio/ktx` with `--access public --tag latest`, runs -the published package smoke test, creates a GitHub release, and commits the -release metadata. +the published package smoke test, and creates a GitHub release. Stable releases +don't commit release metadata back to `main`, because `main` is protected and +requires changes through pull requests. ## Release metadata @@ -105,7 +106,8 @@ prepare step. That script updates: The artifact packaging and readiness scripts read `publicNpmPackageVersion` from `release-policy.json`, so manual version edits in build scripts aren't -needed for rc releases. +needed for rc releases. Stable releases use the updated metadata during the +workflow run, but that generated metadata isn't committed back to `main`. The bundled Python runtime wheel also derives its version from `publicNpmPackageVersion`. Stable npm versions are reused as-is, and rc diff --git a/scripts/semantic-release-config.cjs b/scripts/semantic-release-config.cjs index 347cf5f6..d3791c1f 100644 --- a/scripts/semantic-release-config.cjs +++ b/scripts/semantic-release-config.cjs @@ -90,6 +90,26 @@ function releaseTag(kind) { return kind === 'rc' ? 'next' : 'latest'; } +function releaseChangelogPlugins(kind) { + return kind === 'rc' ? ['@semantic-release/changelog'] : []; +} + +function releaseGitPlugins(kind) { + if (kind !== 'rc') { + return []; + } + + return [ + [ + '@semantic-release/git', + { + assets: ['CHANGELOG.md', 'package.json', 'release-policy.json'], + message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}', + }, + ], + ]; +} + function releaseBranches(env = process.env) { const branch = currentBranch(env); const kind = releaseKind(env); @@ -138,7 +158,7 @@ function createReleaseConfig(env = process.env) { }, ], './scripts/semantic-release-version-policy.cjs', - '@semantic-release/changelog', + ...releaseChangelogPlugins(kind), [ '@semantic-release/exec', { @@ -153,13 +173,7 @@ function createReleaseConfig(env = process.env) { ].join(' && '), }, ], - [ - '@semantic-release/git', - { - assets: ['CHANGELOG.md', 'package.json', 'release-policy.json'], - message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}', - }, - ], + ...releaseGitPlugins(kind), [ '@semantic-release/github', { diff --git a/scripts/semantic-release-config.test.mjs b/scripts/semantic-release-config.test.mjs index d52fbd7a..a4b1b28d 100644 --- a/scripts/semantic-release-config.test.mjs +++ b/scripts/semantic-release-config.test.mjs @@ -9,6 +9,14 @@ function releaseExecOptions(config) { return config.plugins.find((plugin) => Array.isArray(plugin) && plugin[0] === '@semantic-release/exec' && plugin[1].prepareCmd)[1]; } +function releaseExecIndex(config) { + return config.plugins.findIndex((plugin) => Array.isArray(plugin) && plugin[0] === '@semantic-release/exec' && plugin[1].prepareCmd); +} + +function pluginNames(config) { + return config.plugins.map((plugin) => (Array.isArray(plugin) ? plugin[0] : plugin)); +} + describe('semantic-release config', () => { it('configures rc releases on a dedicated next prerelease branch', () => { assert.equal(releaseKind({ KTX_RELEASE_KIND: 'rc' }), 'rc'); @@ -23,6 +31,14 @@ describe('semantic-release config', () => { releaseExecOptions(config).prepareCmd, /update-public-release-version\.mjs "\$\{nextRelease\.version\}" "next"/, ); + const releaseFilePluginNames = pluginNames(config).filter( + (plugin) => plugin === '@semantic-release/changelog' || plugin === '@semantic-release/git', + ); + assert.deepEqual(releaseFilePluginNames, ['@semantic-release/changelog', '@semantic-release/git']); + + const names = pluginNames(config); + assert.ok(names.indexOf('@semantic-release/changelog') < releaseExecIndex(config)); + assert.ok(names.indexOf('@semantic-release/git') > releaseExecIndex(config)); }); it('configures stable releases only from main with latest tag', () => { @@ -38,6 +54,13 @@ describe('semantic-release config', () => { assert.ok(config.plugins.includes('./scripts/semantic-release-version-policy.cjs')); }); + it('does not commit release files back to protected main during stable releases', () => { + const config = createReleaseConfig({ KTX_RELEASE_KIND: 'stable', GITHUB_REF_NAME: 'main' }); + + assert.equal(pluginNames(config).includes('@semantic-release/git'), false); + assert.equal(pluginNames(config).includes('@semantic-release/changelog'), false); + }); + it('rejects stable releases from non-main branches', () => { assert.throws( () => releaseBranches({ KTX_RELEASE_KIND: 'stable', GITHUB_REF_NAME: 'feature/release-test' }),