From 01afe5227d602a3d7cf648f33b1b57729ed17bd3 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 12 Oct 2026 18:51:34 -0700 Subject: [PATCH] ci(release): start a stable release's notes at the previous stable tag The release workflow generated the changelog entry and release notes from the highest existing tag. Once a release candidate is tagged, that is the candidate, so promoting it to stable diffed the release commit against its own candidate: v0.7.0 would have covered zero commits, and git-iris fails on empty notes. A stable version now starts from the previous stable tag, so its notes cover everything since then, its candidates included. Prereleases keep starting from the latest tag, and a first stable release with only candidates before it still falls back to the latest candidate. The workflow test returns the step's outputs and pins all three cases; on the previous workflow the stable case fails. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 10 +++++++++ scripts/tests/release-workflow.test.mjs | 27 +++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 94641fda0..11a25e6c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -168,6 +168,16 @@ jobs: prerelease=false [[ "${version}" == *-* ]] && prerelease=true from_ref="${last_tag}" + # A stable release's notes cover everything since the previous + # stable release, its own candidates included; starting from its + # last candidate would leave them nearly empty. + if [[ "${prerelease}" == false ]]; then + last_stable="$(git tag --list 'v*' --sort=-v:refname \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true)" + if [[ -n "${last_stable}" ]]; then + from_ref="${last_stable}" + fi + fi if [[ -z "${from_ref}" ]]; then from_ref="$(git rev-list --max-parents=0 HEAD | tail -n1)" fi diff --git a/scripts/tests/release-workflow.test.mjs b/scripts/tests/release-workflow.test.mjs index 9126f620c..d6b8445f9 100644 --- a/scripts/tests/release-workflow.test.mjs +++ b/scripts/tests/release-workflow.test.mjs @@ -28,11 +28,16 @@ function validateVersion(tags, version) { } // Registry fixtures model an unpublished version, without network access. writeFileSync(path.join(root, 'curl'), '#!/bin/sh\nexit 22\n', { mode: 0o755 }); - return spawnSync('bash', ['-c', stepScript('Validate release version')], { + const outputsPath = path.join(root, 'outputs'); + const result = spawnSync('bash', ['-c', stepScript('Validate release version')], { cwd: root, encoding: 'utf8', - env: { ...process.env, PATH: `${root}:${process.env.PATH}`, INPUT_VERSION: version, GITHUB_OUTPUT: path.join(root, 'outputs') }, + env: { ...process.env, PATH: `${root}:${process.env.PATH}`, INPUT_VERSION: version, GITHUB_OUTPUT: outputsPath }, }); + const outputs = result.status === 0 + ? Object.fromEntries(readFileSync(outputsPath, 'utf8').trim().split('\n').map((line) => line.split('=', 2))) + : {}; + return { ...result, outputs }; } finally { rmSync(root, { recursive: true, force: true }); } @@ -49,6 +54,24 @@ test('prereleases cannot follow their already-published stable version', () => { assert.match(result.stderr, /not above the latest tag v0.5.2\n/); }); +test('a stable release starts its notes at the previous stable release, not its candidates', () => { + const result = validateVersion(['v0.5.1', 'v0.5.2-rc.1', 'v0.5.2-rc.2'], '0.5.2'); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.outputs.from_ref, 'v0.5.1'); +}); + +test('a release candidate starts its notes at the latest tag', () => { + const result = validateVersion(['v0.5.1', 'v0.5.2-rc.1'], '0.5.2-rc.2'); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.outputs.from_ref, 'v0.5.2-rc.1'); +}); + +test('a first stable release with only candidates before it starts at the latest candidate', () => { + const result = validateVersion(['v0.1.0-rc.1'], '0.1.0'); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.outputs.from_ref, 'v0.1.0-rc.1'); +}); + test('prerelease sequencing remains numeric and rejects downgrades', () => { for (const [previous, next, passes] of [ ['0.5.2-rc.2', '0.5.2-rc.10', true],