Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/scripts/previous-stable-tag.sh
Original file line number Diff line number Diff line change
@@ -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 <current-tag>
#
# 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") <current-tag>" >&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 }'
31 changes: 31 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions docs/src/content/docs/release-orchestration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions internal/release/previous_stable_tag_test.go
Original file line number Diff line number Diff line change
@@ -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)
}