fix(release): repair next npm release workflow (#122)

* fix(ci): run rc releases from next branch

* fix(context): allow release git askpass env

* fix(release): make npm publish noninteractive

* fix(release): use npm trusted publishing

* fix(release): tolerate npm propagation in smoke

* docs(release): document trusted publishing auth
This commit is contained in:
Andrey Avtomonov 2026-05-17 01:41:07 +02:00 committed by GitHub
parent de72a10ffb
commit d3d58a279b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 232 additions and 40 deletions

View file

@ -24,6 +24,7 @@ on:
permissions: permissions:
contents: write contents: write
id-token: write
concurrency: concurrency:
group: ktx-release-${{ github.ref }} group: ktx-release-${{ github.ref }}
@ -68,19 +69,59 @@ jobs:
- name: Install Python dependencies - name: Install Python dependencies
run: uv sync --all-packages run: uv sync --all-packages
- name: Prepare next prerelease branch
if: ${{ inputs.release_kind == 'rc' }}
run: |
set -euo pipefail
source_sha="$(git rev-parse HEAD)"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git ls-remote --exit-code --heads origin "${KTX_PRERELEASE_BRANCH}" >/dev/null 2>&1; then
git fetch origin "${KTX_PRERELEASE_BRANCH}"
git checkout -B "${KTX_PRERELEASE_BRANCH}" "origin/${KTX_PRERELEASE_BRANCH}"
git merge --no-edit "${source_sha}"
else
git checkout -B "${KTX_PRERELEASE_BRANCH}" "${source_sha}"
fi
git push --set-upstream origin "HEAD:${KTX_PRERELEASE_BRANCH}"
env:
KTX_PRERELEASE_BRANCH: next
- name: Dry-run semantic release - name: Dry-run semantic release
if: ${{ !inputs.publish_live }} if: ${{ !inputs.publish_live }}
run: pnpm run semantic-release:dry-run run: |
set -euo pipefail
if [ "${KTX_RELEASE_KIND}" = "rc" ]; then
export GITHUB_REF="refs/heads/${KTX_PRERELEASE_BRANCH}"
export GITHUB_REF_NAME="${KTX_PRERELEASE_BRANCH}"
export GITHUB_SHA="$(git rev-parse HEAD)"
fi
pnpm run semantic-release:dry-run
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
KTX_RELEASE_KIND: ${{ inputs.release_kind }} KTX_RELEASE_KIND: ${{ inputs.release_kind }}
KTX_PRERELEASE_BRANCH: next
FORCE_RELEASE: ${{ inputs.force_release }} FORCE_RELEASE: ${{ inputs.force_release }}
- name: Create semantic release - name: Create semantic release
if: ${{ inputs.publish_live }} if: ${{ inputs.publish_live }}
run: pnpm run semantic-release run: |
set -euo pipefail
if [ "${KTX_RELEASE_KIND}" = "rc" ]; then
export GITHUB_REF="refs/heads/${KTX_PRERELEASE_BRANCH}"
export GITHUB_REF_NAME="${KTX_PRERELEASE_BRANCH}"
export GITHUB_SHA="$(git rev-parse HEAD)"
fi
pnpm run semantic-release
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
KTX_RELEASE_KIND: ${{ inputs.release_kind }} KTX_RELEASE_KIND: ${{ inputs.release_kind }}
KTX_PRERELEASE_BRANCH: next
FORCE_RELEASE: ${{ inputs.force_release }} FORCE_RELEASE: ${{ inputs.force_release }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View file

@ -12,6 +12,11 @@ KTX has two npm release channels:
- `rc` publishes prereleases such as `0.1.0-rc.2` to the npm `next` tag. - `rc` publishes prereleases such as `0.1.0-rc.2` to the npm `next` tag.
- `stable` publishes normal releases such as `0.1.0` to the npm `latest` tag. - `stable` publishes normal releases such as `0.1.0` to the npm `latest` tag.
Run rc releases from the source branch you want to publish. The workflow
creates or updates the `next` prerelease branch from that source branch before
running semantic-release, because semantic-release requires a dedicated
prerelease branch in addition to the stable `main` branch.
Run stable releases only from `main`. The workflow rejects stable releases from Run stable releases only from `main`. The workflow rejects stable releases from
other branches. other branches.
@ -19,10 +24,11 @@ other branches.
Before you publish, confirm these requirements: Before you publish, confirm these requirements:
- The repository has an Actions secret named `NPM_TOKEN`. - npm Trusted Publishing is configured for `@kaelio/ktx`.
- `NPM_TOKEN` is a granular npm token that can publish `@kaelio/ktx`. - The trusted publisher points at the `Kaelio/ktx` repository and the
- The token can publish non-interactively if the npm account or package uses `.github/workflows/release.yml` workflow.
two-factor authentication for writes. - 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 - The repository has a baseline semantic-release tag for the latest published
package version, such as `v0.1.0-rc.1`. package version, such as `v0.1.0-rc.1`.
@ -43,8 +49,9 @@ publishing to npm.
if semantic-release doesn't find a releasable commit. if semantic-release doesn't find a releasable commit.
7. Run the workflow. 7. Run the workflow.
The dry-run uses the same semantic-release configuration as a live release. It The dry-run uses the same semantic-release configuration as a live release. For
doesn't publish to npm and doesn't commit release files. rc releases, it can create or update the `next` branch. It doesn't publish to
npm and doesn't commit release files.
## Publish an rc release ## Publish an rc release
@ -53,15 +60,16 @@ promoting to `latest`.
1. Open **Actions** in GitHub. 1. Open **Actions** in GitHub.
2. Select **KTX Release**. 2. Select **KTX Release**.
3. Select the branch to release from. 3. Select the source branch to release from.
4. Set **release_kind** to `rc`. 4. Set **release_kind** to `rc`.
5. Set **publish_live** to `true`. 5. Set **publish_live** to `true`.
6. Optional: Set **force_release** to `true`. 6. Optional: Set **force_release** to `true`.
7. Run the workflow. 7. Run the workflow.
The workflow publishes `@kaelio/ktx` with `--access public --tag next`, runs the The workflow merges the selected source branch into `next`, publishes
published package smoke test, creates a GitHub release, and commits `@kaelio/ktx` with `--access public --tag next`, runs the published package
`CHANGELOG.md`, `package.json`, and `release-policy.json`. smoke test, creates a GitHub release, and commits `CHANGELOG.md`,
`package.json`, and `release-policy.json` on `next`.
## Publish a stable release ## Publish a stable release
@ -92,8 +100,11 @@ The artifact packaging and readiness scripts read `publicNpmPackageVersion`
from `release-policy.json`, so manual version edits in build scripts aren't from `release-policy.json`, so manual version edits in build scripts aren't
needed for rc releases. needed for rc releases.
## Trusted Publishing follow-up ## npm authentication
This workflow uses `NPM_TOKEN` today. Move to npm Trusted Publishing after the The release workflow publishes through npm Trusted Publishing. It doesn't use
final publish command path is verified for the package manager and workflow an `NPM_TOKEN` secret, and the publish step doesn't set `NODE_AUTH_TOKEN`.
filename configured in npm package settings.
If npm returns an authentication error, check the Trusted Publishing settings
for the `@kaelio/ktx` package before adding token-based authentication back to
the workflow.

View file

@ -25,5 +25,5 @@ function sanitizedGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEn
} }
export function createSimpleGit(baseDir: string): SimpleGit { export function createSimpleGit(baseDir: string): SimpleGit {
return simpleGit({ baseDir }).env(sanitizedGitEnv()); return simpleGit({ baseDir, unsafe: { allowUnsafeAskPass: true } }).env(sanitizedGitEnv());
} }

View file

@ -59,6 +59,34 @@ describe('GitService', () => {
expect(config).toMatch(/\[gc]\n\s+autoDetach = false/); expect(config).toMatch(/\[gc]\n\s+autoDetach = false/);
expect(config).toMatch(/\[maintenance]\n\s+autoDetach = false/); expect(config).toMatch(/\[maintenance]\n\s+autoDetach = false/);
}); });
it('initializes when release automation sets GIT_ASKPASS', async () => {
const releaseEnvDir = await mkdtemp(join(tmpdir(), 'git-service-release-env-'));
const previousAskPass = process.env.GIT_ASKPASS;
process.env.GIT_ASKPASS = 'echo';
try {
const releaseEnvService = new GitService({
storage: { configDir: releaseEnvDir, homeDir: releaseEnvDir },
git: {
userName: 'Test User',
userEmail: 'test@example.com',
bootstrapMessage: 'Initialize test config repo',
bootstrapAuthor: 'test-system',
bootstrapAuthorEmail: 'system@example.com',
},
});
await expect(releaseEnvService.onModuleInit()).resolves.toBeUndefined();
} finally {
if (previousAskPass === undefined) {
delete process.env.GIT_ASKPASS;
} else {
process.env.GIT_ASKPASS = previousAskPass;
}
await rm(releaseEnvDir, { recursive: true, force: true });
}
});
}); });
describe('commitFile `created` flag', () => { describe('commitFile `created` flag', () => {

View file

@ -3,6 +3,7 @@ import { type SimpleGit, simpleGit } from 'simple-git';
const SANITIZED_GIT_ENV_KEYS = [ const SANITIZED_GIT_ENV_KEYS = [
'EDITOR', 'EDITOR',
'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
'GIT_ASKPASS',
'GIT_CONFIG', 'GIT_CONFIG',
'GIT_CONFIG_COUNT', 'GIT_CONFIG_COUNT',
'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_GLOBAL',
@ -31,5 +32,5 @@ export function createSimpleGit(baseDir?: string): SimpleGit {
for (const key of SANITIZED_GIT_ENV_KEYS) { for (const key of SANITIZED_GIT_ENV_KEYS) {
delete env[key]; delete env[key];
} }
return simpleGit(baseDir).env(env); return simpleGit({ baseDir, unsafe: { allowUnsafeAskPass: true } }).env(env);
} }

View file

@ -1,14 +1,13 @@
#!/usr/bin/env node #!/usr/bin/env node
import { execFile } from 'node:child_process'; import { spawn } from 'node:child_process';
import { access } from 'node:fs/promises'; import { access } from 'node:fs/promises';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { promisify } from 'node:util';
import { packageArtifactLayout } from './package-artifacts.mjs'; import { packageArtifactLayout } from './package-artifacts.mjs';
import { releaseReadinessReport } from './release-readiness.mjs'; import { releaseReadinessReport } from './release-readiness.mjs';
const execFileAsync = promisify(execFile); export const NPM_PUBLISH_TIMEOUT_MS = 180_000;
export function resolvePublishMode(args = process.argv.slice(2)) { export function resolvePublishMode(args = process.argv.slice(2)) {
return { live: args.includes('--publish') }; return { live: args.includes('--publish') };
@ -23,7 +22,7 @@ export function requireNpmPublicReleaseReady(report) {
export function buildNpmPublishCommand(tarballPath, publish, mode) { export function buildNpmPublishCommand(tarballPath, publish, mode) {
return { return {
command: 'pnpm', command: 'npm',
args: [ args: [
'publish', 'publish',
tarballPath, tarballPath,
@ -31,7 +30,7 @@ export function buildNpmPublishCommand(tarballPath, publish, mode) {
publish.access, publish.access,
'--tag', '--tag',
publish.tag, publish.tag,
...(mode.live ? [] : ['--dry-run', '--no-git-checks']), ...(mode.live ? [] : ['--dry-run']),
], ],
env: publish.registry ? { npm_config_registry: publish.registry } : {}, env: publish.registry ? { npm_config_registry: publish.registry } : {},
}; };
@ -47,10 +46,42 @@ async function assertFileExists(path) {
async function runPublishCommand(command) { async function runPublishCommand(command) {
process.stdout.write(`$ ${command.command} ${command.args.join(' ')}\n`); process.stdout.write(`$ ${command.command} ${command.args.join(' ')}\n`);
await execFileAsync(command.command, command.args, {
env: { ...process.env, ...command.env }, await new Promise((resolvePromise, reject) => {
encoding: 'utf8', let settled = false;
maxBuffer: 1024 * 1024 * 20, const child = spawn(command.command, command.args, {
env: { ...process.env, ...command.env },
stdio: ['ignore', 'pipe', 'pipe'],
});
const settle = (callback, value) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
callback(value);
};
const timeout = setTimeout(() => {
child.kill('SIGTERM');
settle(reject, new Error(`Timed out after ${NPM_PUBLISH_TIMEOUT_MS}ms while publishing npm package`));
}, NPM_PUBLISH_TIMEOUT_MS);
child.stdout.on('data', (chunk) => {
process.stdout.write(chunk);
});
child.stderr.on('data', (chunk) => {
process.stderr.write(chunk);
});
child.on('error', (error) => {
settle(reject, error);
});
child.on('close', (code, signal) => {
if (code === 0) {
settle(resolvePromise);
return;
}
settle(reject, new Error(`npm publish failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`));
});
}); });
} }

View file

@ -49,13 +49,13 @@ describe('requireNpmPublicReleaseReady', () => {
}); });
describe('buildNpmPublishCommand', () => { describe('buildNpmPublishCommand', () => {
it('builds a dry-run pnpm publish command by default', () => { it('builds a dry-run npm publish command by default', () => {
assert.deepEqual( assert.deepEqual(
buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz', readyReport.npmPublish, { buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz', readyReport.npmPublish, {
live: false, live: false,
}), }),
{ {
command: 'pnpm', command: 'npm',
args: [ args: [
'publish', 'publish',
'/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz', '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz',
@ -64,7 +64,6 @@ describe('buildNpmPublishCommand', () => {
'--tag', '--tag',
'next', 'next',
'--dry-run', '--dry-run',
'--no-git-checks',
], ],
env: {}, env: {},
}, },

View file

@ -2,7 +2,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises'; import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
@ -22,6 +22,8 @@ export {
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const SMOKE_TIMEOUT_MS = 180_000; const SMOKE_TIMEOUT_MS = 180_000;
const TRANSIENT_LOOKUP_RETRY_ATTEMPTS = 6;
const TRANSIENT_LOOKUP_RETRY_DELAY_MS = 10_000;
const VERSION_LABELS = new Set([ const VERSION_LABELS = new Set([
'published package npx version', 'published package npx version',
@ -33,6 +35,18 @@ export function isPublishedPackageVersionLabel(label) {
return VERSION_LABELS.has(label); return VERSION_LABELS.has(label);
} }
export function publishedPackageSmokePnpmWorkspaceYaml() {
return ['packages:', ' - "."', 'allowBuilds:', ' better-sqlite3: true', ''].join('\n');
}
export function isTransientPublishedPackageLookupFailure(result) {
return (
result.code !== 0 &&
(result.stderr.includes('npm error code ETARGET') ||
result.stderr.includes('No matching version found for @kaelio/ktx@'))
);
}
function scriptRootDir() { function scriptRootDir() {
return resolve(dirname(fileURLToPath(import.meta.url)), '..'); return resolve(dirname(fileURLToPath(import.meta.url)), '..');
} }
@ -61,6 +75,24 @@ async function runCommand(command, args, options = {}) {
} }
} }
function delay(ms) {
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
}
async function runCommandWithRegistryRetry(command, args, options = {}) {
const attempts = options.retryAttempts ?? TRANSIENT_LOOKUP_RETRY_ATTEMPTS;
const retryDelayMs = options.retryDelayMs ?? TRANSIENT_LOOKUP_RETRY_DELAY_MS;
let result = await runCommand(command, args, options);
for (let attempt = 2; attempt <= attempts && isTransientPublishedPackageLookupFailure(result); attempt += 1) {
process.stdout.write(`npm registry has not exposed the package yet; retrying smoke command (${attempt}/${attempts})\n`);
await delay(retryDelayMs);
result = await runCommand(command, args, options);
}
return result;
}
function requireSuccess(label, result) { function requireSuccess(label, result) {
assert.equal( assert.equal(
result.code, result.code,
@ -74,15 +106,17 @@ export async function runPublishedPackageSmoke(config) {
try { try {
const projectDir = join(root, 'demo-project'); const projectDir = join(root, 'demo-project');
await writeFile(join(root, 'pnpm-workspace.yaml'), publishedPackageSmokePnpmWorkspaceYaml());
const commands = buildPublishedPackageSmokeCommands(config, projectDir); const commands = buildPublishedPackageSmokeCommands(config, projectDir);
const pnpmHome = join(root, 'pnpm-home'); const pnpmHome = join(root, 'pnpm-home');
const globalEnv = { const globalEnv = {
PNPM_HOME: pnpmHome, PNPM_HOME: pnpmHome,
PATH: `${pnpmHome}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH ?? ''}`, PATH: [join(pnpmHome, 'bin'), pnpmHome, process.env.PATH ?? ''].join(process.platform === 'win32' ? ';' : ':'),
}; };
for (const command of commands) { for (const command of commands) {
const isGlobalCommand = command.label.includes('global'); const isGlobalCommand = command.label.includes('global');
const result = await runCommand(command.command, command.args, { const result = await runCommandWithRegistryRetry(command.command, command.args, {
cwd: command.label.includes('local') || isGlobalCommand ? root : undefined, cwd: command.label.includes('local') || isGlobalCommand ? root : undefined,
env: isGlobalCommand ? { ...globalEnv, ...command.env } : command.env, env: isGlobalCommand ? { ...globalEnv, ...command.env } : command.env,
}); });

View file

@ -6,6 +6,8 @@ import {
buildPublishedPackageNpxCommand, buildPublishedPackageNpxCommand,
buildPublishedPackageSmokeCommands, buildPublishedPackageSmokeCommands,
isPublishedPackageVersionLabel, isPublishedPackageVersionLabel,
isTransientPublishedPackageLookupFailure,
publishedPackageSmokePnpmWorkspaceYaml,
publishedPackageSpec, publishedPackageSpec,
readPublishedPackageSmokeConfig, readPublishedPackageSmokeConfig,
} from './published-package-smoke.mjs'; } from './published-package-smoke.mjs';
@ -156,6 +158,33 @@ describe('published package smoke output validation labels', () => {
}); });
}); });
describe('published package smoke registry retry classification', () => {
it('recognizes npm propagation misses as transient lookup failures', () => {
assert.equal(
isTransientPublishedPackageLookupFailure({
code: 1,
stdout: '',
stderr: [
'npm error code ETARGET',
'npm error notarget No matching version found for @kaelio/ktx@0.1.0-rc.4.',
].join('\n'),
}),
true,
);
});
it('does not retry unrelated command failures', () => {
assert.equal(
isTransientPublishedPackageLookupFailure({
code: 1,
stdout: '',
stderr: 'npm error code EOTP',
}),
false,
);
});
});
describe('published package smoke command construction', () => { describe('published package smoke command construction', () => {
const config = { const config = {
enabled: true, enabled: true,
@ -244,6 +273,13 @@ describe('published package smoke command construction', () => {
); );
}); });
it('allows native dependency build scripts in clean pnpm smoke installs', () => {
assert.equal(
publishedPackageSmokePnpmWorkspaceYaml(),
['packages:', ' - "."', 'allowBuilds:', ' better-sqlite3: true', ''].join('\n'),
);
});
it('exposes the smoke through the package release script', async () => { it('exposes the smoke through the package release script', async () => {
const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));

View file

@ -14,13 +14,18 @@ describe('release workflow', () => {
assert.match(workflow, /publish_live:/); assert.match(workflow, /publish_live:/);
assert.match(workflow, /default: false/); assert.match(workflow, /default: false/);
assert.match(workflow, /^ contents: write$/m); assert.match(workflow, /^ contents: write$/m);
assert.match(workflow, /^ id-token: write$/m);
assert.match(workflow, /fetch-depth: 0/); assert.match(workflow, /fetch-depth: 0/);
assert.match(workflow, /registry-url: "https:\/\/registry\.npmjs\.org"/); assert.match(workflow, /registry-url: "https:\/\/registry\.npmjs\.org"/);
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\}"/);
assert.match(workflow, /pnpm run semantic-release:dry-run/); assert.match(workflow, /pnpm run semantic-release:dry-run/);
assert.match(workflow, /pnpm run semantic-release$/m); assert.match(workflow, /pnpm run semantic-release$/m);
assert.match(workflow, /KTX_RELEASE_KIND: \$\{\{ inputs.release_kind \}\}/); assert.match(workflow, /KTX_RELEASE_KIND: \$\{\{ inputs.release_kind \}\}/);
assert.match(workflow, /KTX_PRERELEASE_BRANCH: next/);
assert.match(workflow, /FORCE_RELEASE: \$\{\{ inputs.force_release \}\}/); assert.match(workflow, /FORCE_RELEASE: \$\{\{ inputs.force_release \}\}/);
assert.match(workflow, /NODE_AUTH_TOKEN: \$\{\{ secrets.NPM_TOKEN \}\}/); assert.doesNotMatch(workflow, /NODE_AUTH_TOKEN/);
assert.doesNotMatch(workflow, /^ push:/m); assert.doesNotMatch(workflow, /^ push:/m);
assert.doesNotMatch(workflow, /^ pull_request:/m); assert.doesNotMatch(workflow, /^ pull_request:/m);
}); });

View file

@ -82,6 +82,10 @@ function releaseKind(env) {
return env.KTX_RELEASE_KIND || env.INPUT_RELEASE_KIND || 'rc'; return env.KTX_RELEASE_KIND || env.INPUT_RELEASE_KIND || 'rc';
} }
function prereleaseBranch(env) {
return env.KTX_PRERELEASE_BRANCH || env.INPUT_PRERELEASE_BRANCH || 'next';
}
function releaseTag(kind) { function releaseTag(kind) {
return kind === 'rc' ? 'next' : 'latest'; return kind === 'rc' ? 'next' : 'latest';
} }
@ -91,7 +95,7 @@ function releaseBranches(env = process.env) {
const kind = releaseKind(env); const kind = releaseKind(env);
if (kind === 'rc') { if (kind === 'rc') {
return [{ name: branch, prerelease: 'rc', channel: 'next' }]; return ['main', { name: prereleaseBranch(env), prerelease: 'rc', channel: 'next' }];
} }
if (kind === 'stable') { if (kind === 'stable') {
@ -170,6 +174,7 @@ function createReleaseConfig(env = process.env) {
module.exports = { module.exports = {
createReleaseConfig, createReleaseConfig,
prereleaseBranch,
releaseBranches, releaseBranches,
releaseKind, releaseKind,
releaseTag, releaseTag,

View file

@ -10,14 +10,15 @@ function releaseExecOptions(config) {
} }
describe('semantic-release config', () => { describe('semantic-release config', () => {
it('configures manual rc releases on the selected branch with next channel', () => { it('configures rc releases on a dedicated next prerelease branch', () => {
assert.equal(releaseKind({ KTX_RELEASE_KIND: 'rc' }), 'rc'); assert.equal(releaseKind({ KTX_RELEASE_KIND: 'rc' }), 'rc');
assert.equal(releaseTag('rc'), 'next'); assert.equal(releaseTag('rc'), 'next');
assert.deepEqual(releaseBranches({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'release-candidate' }), [ assert.deepEqual(releaseBranches({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'main' }), [
{ name: 'release-candidate', prerelease: 'rc', channel: 'next' }, 'main',
{ name: 'next', prerelease: 'rc', channel: 'next' },
]); ]);
const config = createReleaseConfig({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'release-candidate' }); const config = createReleaseConfig({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'main' });
assert.match( assert.match(
releaseExecOptions(config).prepareCmd, releaseExecOptions(config).prepareCmd,
/update-public-release-version\.mjs "\$\{nextRelease\.version\}" "next"/, /update-public-release-version\.mjs "\$\{nextRelease\.version\}" "next"/,