diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6732cf13..81a1196e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,19 +68,60 @@ jobs: - name: Install Python dependencies 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 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: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} KTX_RELEASE_KIND: ${{ inputs.release_kind }} + KTX_PRERELEASE_BRANCH: next FORCE_RELEASE: ${{ inputs.force_release }} - name: Create semantic release 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: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} KTX_RELEASE_KIND: ${{ inputs.release_kind }} + KTX_PRERELEASE_BRANCH: next FORCE_RELEASE: ${{ inputs.force_release }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/docs/release.md b/docs/release.md index 670a6a70..075e2845 100644 --- a/docs/release.md +++ b/docs/release.md @@ -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. - `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 other branches. @@ -43,8 +48,9 @@ publishing to npm. if semantic-release doesn't find a releasable commit. 7. Run the workflow. -The dry-run uses the same semantic-release configuration as a live release. It -doesn't publish to npm and doesn't commit release files. +The dry-run uses the same semantic-release configuration as a live release. For +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 @@ -53,15 +59,16 @@ promoting to `latest`. 1. Open **Actions** in GitHub. 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`. 5. Set **publish_live** to `true`. 6. Optional: Set **force_release** to `true`. 7. Run the workflow. -The workflow publishes `@kaelio/ktx` with `--access public --tag next`, runs the -published package smoke test, creates a GitHub release, and commits -`CHANGELOG.md`, `package.json`, and `release-policy.json`. +The workflow merges the selected source branch into `next`, publishes +`@kaelio/ktx` with `--access public --tag next`, runs the published package +smoke test, creates a GitHub release, and commits `CHANGELOG.md`, +`package.json`, and `release-policy.json` on `next`. ## Publish a stable release diff --git a/scripts/release-workflow.test.mjs b/scripts/release-workflow.test.mjs index 8e8d6df0..d2936207 100644 --- a/scripts/release-workflow.test.mjs +++ b/scripts/release-workflow.test.mjs @@ -16,9 +16,13 @@ describe('release workflow', () => { assert.match(workflow, /^ contents: write$/m); assert.match(workflow, /fetch-depth: 0/); 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$/m); 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, /NODE_AUTH_TOKEN: \$\{\{ secrets.NPM_TOKEN \}\}/); assert.doesNotMatch(workflow, /^ push:/m); diff --git a/scripts/semantic-release-config.cjs b/scripts/semantic-release-config.cjs index 85df2620..66b9aad7 100644 --- a/scripts/semantic-release-config.cjs +++ b/scripts/semantic-release-config.cjs @@ -82,6 +82,10 @@ function releaseKind(env) { 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) { return kind === 'rc' ? 'next' : 'latest'; } @@ -91,7 +95,7 @@ function releaseBranches(env = process.env) { const kind = releaseKind(env); if (kind === 'rc') { - return [{ name: branch, prerelease: 'rc', channel: 'next' }]; + return ['main', { name: prereleaseBranch(env), prerelease: 'rc', channel: 'next' }]; } if (kind === 'stable') { @@ -170,6 +174,7 @@ function createReleaseConfig(env = process.env) { module.exports = { createReleaseConfig, + prereleaseBranch, releaseBranches, releaseKind, releaseTag, diff --git a/scripts/semantic-release-config.test.mjs b/scripts/semantic-release-config.test.mjs index f61d3498..0a267d77 100644 --- a/scripts/semantic-release-config.test.mjs +++ b/scripts/semantic-release-config.test.mjs @@ -10,14 +10,15 @@ function releaseExecOptions(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(releaseTag('rc'), 'next'); - assert.deepEqual(releaseBranches({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'release-candidate' }), [ - { name: 'release-candidate', prerelease: 'rc', channel: 'next' }, + assert.deepEqual(releaseBranches({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'main' }), [ + '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( releaseExecOptions(config).prepareCmd, /update-public-release-version\.mjs "\$\{nextRelease\.version\}" "next"/,