From 297d149540a24380049a7dcecb2f7ca2fd57a848 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 5 Jul 2026 17:49:04 -0400 Subject: [PATCH] fix(release): span the final-release changelog since the previous stable tag Signed-off-by: Joshua Temple --- .github/scripts/previous-stable-tag.sh | 82 ++++++++++++++ .github/workflows/release.yaml | 31 ++++++ .../src/content/docs/release-orchestration.md | 12 ++ internal/release/previous_stable_tag_test.go | 104 ++++++++++++++++++ 4 files changed, 229 insertions(+) create mode 100755 .github/scripts/previous-stable-tag.sh create mode 100644 internal/release/previous_stable_tag_test.go diff --git a/.github/scripts/previous-stable-tag.sh b/.github/scripts/previous-stable-tag.sh new file mode 100755 index 00000000..d950a28f --- /dev/null +++ b/.github/scripts/previous-stable-tag.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# +# previous-stable-tag.sh: print the previous final (non-prerelease) release tag +# strictly below a given tag, or an empty string when none exists. +# +# Auto-promote tags the newest candidate's exact commit as the final vX.Y.Z, so +# a final tag and its last candidate (for example v0.8.0 and v0.8.0-rc.7) point +# at the same commit. GoReleaser, told only GORELEASER_CURRENT_TAG, auto-detects +# the previous tag as the immediately preceding semver tag, which is that same +# candidate. The GitHub compare range is then empty and the final release ships +# with an empty changelog. Feeding GoReleaser the previous *stable* tag as +# GORELEASER_PREVIOUS_TAG makes the final changelog span the whole candidate +# cycle (for example v0.7.0..v0.8.0) instead. +# +# This computes that previous stable tag. It keeps only final tags shaped +# vMAJOR.MINOR.PATCH (no -rc. / -dryrun. / other prerelease suffix), keeps those +# strictly less than the current tag by semver, and prints the greatest. Any +# prerelease suffix on the current tag is stripped first, so a prerelease current +# tag still resolves the stable release below it (the workflow only uses the +# value for final builds, but the behaviour is well defined either way). +# +# Tags come from `git tag` in the working directory, so the caller must have +# fetched tags first (release.yaml checks out with fetch-depth: 0). For hermetic +# testing, set CASCADE_TAG_LIST to a newline-separated tag list to bypass git. +# +# Usage: +# previous-stable-tag.sh +# +# Exit status: +# 0 success (prints the previous stable tag, or empty when none exists) +# 2 usage / argument error + +set -euo pipefail + +usage() { + echo "usage: $(basename "$0") " >&2 +} + +if [ "$#" -ne 1 ]; then + usage + exit 2 +fi + +current="$1" + +if [ -z "$current" ]; then + usage + exit 2 +fi + +# Strip any prerelease suffix so the comparison target is a pure final version. +# For a final current tag this is a no-op; for vX.Y.Z-rc.N it yields vX.Y.Z, and +# the stable release below that base is what we want. +current_base="${current%%-*}" + +# A defined CASCADE_TAG_LIST (even empty) overrides git, so a test can inject an +# empty tag set. Only a wholly unset variable falls through to `git tag`. +if [ -n "${CASCADE_TAG_LIST+set}" ]; then + tags="$CASCADE_TAG_LIST" +else + tags="$(git tag)" +fi + +# Keep only final tags. A `|| true` absorbs grep's exit 1 on no match so an empty +# tag set does not trip `set -o pipefail`. +finals="$(printf '%s\n' "$tags" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true)" + +# No final tags at all means there is no previous stable release to point at. +if [ -z "$finals" ]; then + printf '\n' + exit 0 +fi + +# Append the current base as a sentinel, sort every final tag plus the sentinel +# by semver (`-u` collapses the sentinel into a final current tag that is already +# present, so it never appears twice), and print the tag immediately below the +# sentinel. That is the greatest final tag strictly less than the current base. +# When nothing sorts below the sentinel (no prior stable), prev is empty and an +# empty line prints. +printf '%s\n%s\n' "$finals" "$current_base" \ + | sort -V -u \ + | awk -v cur="$current_base" '$0 == cur { print prev; exit } { prev = $0 }' diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4ff9c4d9..b2efa023 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -86,6 +86,36 @@ jobs: - name: Install syft uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + # Auto-promote tags the newest candidate's exact commit as the final + # vX.Y.Z, so a final tag and its last candidate (for example v0.8.0 and + # v0.8.0-rc.7) share one commit. GoReleaser, given only the current tag, + # auto-detects the previous tag as that candidate and produces an empty + # compare range, hence an empty changelog on the final release. For a + # final tag we pin GORELEASER_PREVIOUS_TAG to the previous stable release + # so the changelog spans the whole candidate cycle (v0.7.0..v0.8.0). For a + # candidate or dry-run tag we leave it unset so GoReleaser keeps its + # existing incremental changelog. + - name: Resolve previous stable tag for final changelog + env: + REF_NAME: ${{ github.ref_name }} + run: | + tag="$REF_NAME" + case "$tag" in + *-rc.*|*-dryrun.*) + echo "prerelease tag ${tag}; leaving GORELEASER_PREVIOUS_TAG unset" + ;; + *) + git fetch --tags --force + prev="$(.github/scripts/previous-stable-tag.sh "$tag")" + if [ -n "$prev" ]; then + echo "final tag ${tag}; changelog spans ${prev}..${tag}" + echo "GORELEASER_PREVIOUS_TAG=${prev}" >> "$GITHUB_ENV" + else + echo "final tag ${tag}; no previous stable, leaving GORELEASER_PREVIOUS_TAG unset" + fi + ;; + esac + - name: Run GoReleaser uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: @@ -94,6 +124,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GORELEASER_CURRENT_TAG: ${{ github.ref_name }} + GORELEASER_PREVIOUS_TAG: ${{ env.GORELEASER_PREVIOUS_TAG }} GPG_FINGERPRINT: ${{ secrets.CASCADE_RELEASE_GPG_FINGERPRINT }} - name: Attest build provenance diff --git a/docs/src/content/docs/release-orchestration.md b/docs/src/content/docs/release-orchestration.md index 0ebd7c6e..af60455f 100644 --- a/docs/src/content/docs/release-orchestration.md +++ b/docs/src/content/docs/release-orchestration.md @@ -70,6 +70,18 @@ committed bootstrap pin onto it. Because the fleet already validated that exact across every repository before it published, the bump is a proven no-op check rather than a gate that can block a release. +### Changelog range on a final release + +Auto-promote tags the newest candidate's exact commit as the final `vX.Y.Z`, so a final +release and its last candidate (for example `v0.8.0` and `v0.8.0-rc.7`) point at the same +commit. Left to auto-detect the previous tag, GoReleaser would pick that candidate and +compare a tag against itself, so the final release would publish an empty changelog. The +Release workflow avoids this: for a final tag it resolves the previous stable release with +[`previous-stable-tag.sh`](https://github.com/stablekernel/cascade/blob/main/.github/scripts/previous-stable-tag.sh) +and passes it as `GORELEASER_PREVIOUS_TAG`, so a final changelog spans the whole candidate +cycle (for example `v0.7.0..v0.8.0`). Candidate and dry-run tags leave the value unset and +keep their existing incremental changelogs. + ## Running a single lane with the repos selector A full fan-out is the right gate for a release, but it is heavy for developing one diff --git a/internal/release/previous_stable_tag_test.go b/internal/release/previous_stable_tag_test.go new file mode 100644 index 00000000..9fc5cf5f --- /dev/null +++ b/internal/release/previous_stable_tag_test.go @@ -0,0 +1,104 @@ +package release + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// previousStableTagScript locates previous-stable-tag.sh by walking up from the +// test's working directory to the repo's go.mod, then joining the known script +// path. This keeps the test independent of where go test runs it from. +func previousStableTagScript(t *testing.T) string { + t.Helper() + + dir, err := os.Getwd() + require.NoError(t, err) + + for { + if _, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil { + break + } + parent := filepath.Dir(dir) + require.NotEqual(t, parent, dir, "reached filesystem root without finding go.mod") + dir = parent + } + + script := filepath.Join(dir, ".github", "scripts", "previous-stable-tag.sh") + _, err = os.Stat(script) + require.NoError(t, err, "previous-stable-tag.sh not found at %s", script) + return script +} + +// runPreviousStableTag drives the script with an injected tag list (via +// CASCADE_TAG_LIST, so the test needs no git repo) and the given current tag, +// returning the trimmed stdout. +func runPreviousStableTag(t *testing.T, tags []string, current string) string { + t.Helper() + + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash not available; skipping shell script test") + } + + script := previousStableTagScript(t) + cmd := exec.Command(bash, script, current) + cmd.Env = append(os.Environ(), "CASCADE_TAG_LIST="+strings.Join(tags, "\n")) + + out, runErr := cmd.CombinedOutput() + require.NoError(t, runErr, "script should succeed; output: %s", out) + return strings.TrimSpace(string(out)) +} + +func TestPreviousStableTag_FinalWithPriorStableSkipsPrereleases(t *testing.T) { + // A final current tag returns the previous stable, ignoring the candidate + // and dry-run tags that share the release cycle (the bug's exact shape: + // v0.8.0 and v0.8.0-rc.7 point at the same commit). + tags := []string{"v0.6.0", "v0.7.0", "v0.8.0-rc.1", "v0.8.0-rc.7", "v0.8.0-dryrun.2", "v0.8.0"} + require.Equal(t, "v0.7.0", runPreviousStableTag(t, tags, "v0.8.0")) +} + +func TestPreviousStableTag_FinalWithOnlyPrereleasesBelowReturnsEmpty(t *testing.T) { + tags := []string{"v0.8.0-rc.1", "v0.8.0-rc.7", "v0.8.0"} + require.Equal(t, "", runPreviousStableTag(t, tags, "v0.8.0")) +} + +func TestPreviousStableTag_FirstStableReturnsEmpty(t *testing.T) { + tags := []string{"v0.1.0"} + require.Equal(t, "", runPreviousStableTag(t, tags, "v0.1.0")) +} + +func TestPreviousStableTag_MultiplePriorStablesReturnsHighestBelow(t *testing.T) { + // v0.10.0 sorts above v0.8.0 by semver (not lexically) and must be excluded; + // v0.7.0 is the greatest stable strictly below the current tag. + tags := []string{"v0.5.0", "v0.6.0", "v0.7.0", "v0.10.0", "v0.8.0"} + require.Equal(t, "v0.7.0", runPreviousStableTag(t, tags, "v0.8.0")) +} + +func TestPreviousStableTag_PrereleaseCurrentComputesStableBelow(t *testing.T) { + // A prerelease current tag still resolves the stable release below its base + // version (the workflow only uses the value for finals, but the behaviour is + // well defined either way). + tags := []string{"v0.6.0", "v0.7.0", "v0.8.0-rc.7"} + require.Equal(t, "v0.7.0", runPreviousStableTag(t, tags, "v0.8.0-rc.7")) +} + +func TestPreviousStableTag_EmptyTagListReturnsEmpty(t *testing.T) { + require.Equal(t, "", runPreviousStableTag(t, []string{}, "v0.8.0")) +} + +func TestPreviousStableTag_RequiresExactlyOneArgument(t *testing.T) { + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash not available; skipping shell script test") + } + script := previousStableTagScript(t) + + cmd := exec.Command(bash, script) + out, runErr := cmd.CombinedOutput() + require.Error(t, runErr, "missing argument must be a usage error; output: %s", out) +}