feat(ci): reach a milestone's release notes back over its patches

A promotion tags a commit that is already tagged, so GoReleaser's own
previous tag makes the notes on a release consolidating six patches
describe one merge. Emits the last release at the level being cut instead.

Also drops the named-version path: the manual pipeline no longer offers one.

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
This commit is contained in:
pj committed 2026-08-16 15:16:13 +05:30
1 parent 29ba1d34be
commit a45f645309
2 files changed
+112 -83

No files matched your search

+68 -47
View File
@@ -14,9 +14,9 @@ outputs=""
status=0 status=0
stderr="" stderr=""
resolve() { # <case> <bump> <version> <tag>... resolve() { # <case> <bump> <tag>...
local name="$1" bump="$2" version="$3" local name="$1" bump="$2"
shift 3 shift 2
local repo="$work/$name" local repo="$work/$name"
rm -rf "$repo" rm -rf "$repo"
mkdir -p "$repo" mkdir -p "$repo"
@@ -28,7 +28,7 @@ resolve() { # <case> <bump> <version> <tag>...
stderr="$work/$name.err" stderr="$work/$name.err"
: > "$outputs" : > "$outputs"
status=0 status=0
(cd "$repo" && BUMP="$bump" VERSION="$version" GITHUB_OUTPUT="$outputs" \ (cd "$repo" && BUMP="$bump" GITHUB_OUTPUT="$outputs" \
bash -eo pipefail "$script") >/dev/null 2>"$stderr" || status=$? bash -eo pipefail "$script") >/dev/null 2>"$stderr" || status=$?
} }
@@ -44,13 +44,20 @@ expect_version() { # <want> <case>
[ "$status" = 0 ] || fail "$2: exit $status, want 0" [ "$status" = 0 ] || fail "$2: exit $status, want 0"
} }
# The manual pipeline promotes the commit this tag points at, so a wrong answer # The manual pipeline re-cuts the commit this tag points at, so a wrong answer
# here releases the wrong code under the right version. # here releases the wrong code under the right version.
expect_released_tag() { # <want, empty for none> <case> expect_released_tag() { # <want, empty for none> <case>
grep -qxF -- "released_tag=$1" "$outputs" \ grep -qxF -- "released_tag=$1" "$outputs" \
|| fail "$2: $(grep '^released_tag=' "$outputs" || echo 'no released_tag'), want released_tag=$1" || fail "$2: $(grep '^released_tag=' "$outputs" || echo 'no released_tag'), want released_tag=$1"
} }
# How far back GoReleaser reaches for the notes. Empty leaves it on its own
# default, which is the release immediately before this one.
expect_previous_tag() { # <want, empty for none> <case>
grep -qxF -- "previous_tag=$1" "$outputs" \
|| fail "$2: $(grep '^previous_tag=' "$outputs" || echo 'no previous_tag'), want previous_tag=$1"
}
expect_refused() { # <case> <message fragment> expect_refused() { # <case> <message fragment>
[ "$status" != 0 ] || fail "$1: exit 0, want a refusal" [ "$status" != 0 ] || fail "$1: exit 0, want a refusal"
grep -q -- "$2" "$stderr" || fail "$1: refused with '$(cat "$stderr")', want it to mention '$2'" grep -q -- "$2" "$stderr" || fail "$1: refused with '$(cat "$stderr")', want it to mention '$2'"
@@ -58,74 +65,88 @@ expect_refused() { # <case> <message fragment>
} }
# A repository with nothing released yet starts the line at 0.0.1 rather than # A repository with nothing released yet starts the line at 0.0.1 rather than
# reissuing 0.0.0. # reissuing 0.0.0, and has no release to promote or to write notes against.
resolve first patch "" resolve first patch
expect_version 0.0.1 first expect_version 0.0.1 first
expect_released_tag "" first expect_released_tag "" first
expect_previous_tag "" first
# The rc tags this repository carries are candidates for 0.0.1, so the first # The rc tags this repository carries are candidates for 0.0.1, so the first
# stable release is 0.0.1 and not 0.0.2. # stable release is 0.0.1 and not 0.0.2, and a candidate is not a release to
resolve rcs patch "" v0.0.1-rc1 v0.0.1-rc4 # promote.
resolve rcs patch v0.0.1-rc1 v0.0.1-rc4
expect_version 0.0.1 rcs expect_version 0.0.1 rcs
# A candidate is not a release, so there is nothing to promote yet.
expect_released_tag "" rcs expect_released_tag "" rcs
resolve patch patch "" v1.2.3 resolve patch patch v1.2.3
expect_version 1.2.4 patch expect_version 1.2.4 patch
expect_released_tag v1.2.3 patch
# A patch already follows the release before it, so GoReleaser is left alone.
expect_previous_tag "" patch
resolve minor minor "" v1.2.3 resolve minor minor v1.2.3
expect_version 1.3.0 minor expect_version 1.3.0 minor
expect_released_tag v1.2.3 minor
resolve major major "" v1.2.3 resolve major major v1.2.3
expect_version 2.0.0 major expect_version 2.0.0 major
# Lexically 0.9.0 sorts above 0.10.0, so a version-blind sort would count the # Lexically 0.9.0 sorts above 0.10.0, so a version-blind sort would count the
# next patch off the wrong release and hand back 0.9.1. # next patch off the wrong release and hand back 0.9.1.
resolve ordering patch "" v0.9.0 v0.10.0 resolve ordering patch v0.9.0 v0.10.0
expect_version 0.10.1 ordering expect_version 0.10.1 ordering
expect_released_tag v0.10.0 ordering expect_released_tag v0.10.0 ordering
# A tag that is not a release is not a base to count from. # A tag that is not a release is not a base to count from.
resolve noise patch "" v1.2.3 nightly v2.0.0-rc1 vfoo resolve noise patch v1.2.3 nightly v2.0.0-rc1 vfoo
expect_version 1.2.4 noise expect_version 1.2.4 noise
expect_released_tag v1.2.3 noise expect_released_tag v1.2.3 noise
resolve named "" 2.5.0 v1.2.3
expect_version 2.5.0 named
# A named version still promotes the last release rather than some other commit.
expect_released_tag v1.2.3 named
# A named version wins over the bump rather than being combined with it.
resolve named-over-bump major 0.4.0 v1.2.3
expect_version 0.4.0 named-over-bump
resolve named-prerelease "" 1.0.0-rc1 v0.9.0
expect_version 1.0.0-rc1 named-prerelease
resolve named-junk "" "1.0" v1.2.3
expect_refused named-junk "is not a version this releases"
# The refusal has to survive text that would otherwise reach a shell or forge a
# second $GITHUB_OUTPUT key.
resolve named-injection "" '1.0.0; touch /tmp/pwned' v1.2.3
expect_refused named-injection "is not a version this releases"
resolve named-newline "" '1.0.0
version=9.9.9' v1.2.3
expect_refused named-newline "is not a version this releases"
resolve bad-bump sideways "" v1.2.3
expect_refused bad-bump "is not a bump"
# A bump counts off the highest release, so releasing twice in a row advances # A bump counts off the highest release, so releasing twice in a row advances
# twice rather than landing on the tag the first one just cut. # twice rather than landing on the tag the first one just cut.
resolve consecutive patch "" v1.2.3 v1.2.4 resolve consecutive patch v1.2.3 v1.2.4
expect_version 1.2.5 consecutive expect_version 1.2.5 consecutive
# Naming a version that is already tagged would relabel a release people have resolve bad-bump sideways v1.2.3
# already installed. expect_refused bad-bump "is not a bump"
resolve named-already "" 1.2.3 v1.2.3
expect_refused named-already "is already tagged" # --- how far back a milestone's notes reach ----------------------------------
# The whole point of consolidating: 0.2.0's notes have to cover every patch
# since 0.1.0, not just the merge that happened to be last before it.
resolve minor-notes minor v0.1.0 v0.1.1 v0.1.2
expect_version 0.2.0 minor-notes
expect_previous_tag v0.1.0 minor-notes
# The last release at this level, not the first one ever seen at it.
resolve minor-notes-latest minor v0.1.0 v0.2.0 v0.2.1
expect_version 0.3.0 minor-notes-latest
expect_previous_tag v0.2.0 minor-notes-latest
# A major counts as a milestone for a minor's notes: 1.0.0 is where the patches
# being consolidated started.
resolve minor-notes-major minor v0.9.0 v1.0.0 v1.0.1
expect_version 1.1.0 minor-notes-major
expect_previous_tag v1.0.0 minor-notes-major
# The same version-aware ordering the base needs.
resolve minor-notes-ordering minor v0.9.0 v0.10.0 v0.10.1
expect_version 0.11.0 minor-notes-ordering
expect_previous_tag v0.10.0 minor-notes-ordering
# A major reaches back to the last major, not to the last minor.
resolve major-notes major v1.0.0 v1.1.0 v1.1.3
expect_version 2.0.0 major-notes
expect_previous_tag v1.0.0 major-notes
# The first milestone of its kind has nothing at its own level to reach back to,
# so it reaches back to the first release there has ever been.
resolve minor-notes-firstever minor v0.0.1 v0.0.2 v0.0.3
expect_version 0.1.0 minor-notes-firstever
expect_previous_tag v0.0.1 minor-notes-firstever
resolve major-notes-firstever major v0.1.0 v0.2.0 v0.2.1
expect_version 1.0.0 major-notes-firstever
expect_previous_tag v0.1.0 major-notes-firstever
if [ "$failed" = 0 ]; then if [ "$failed" = 0 ]; then
echo "next-version-test: ok" echo "next-version-test: ok"
+44 -36
View File
@@ -4,66 +4,74 @@
# nothing in the tree holds it: no commit has to land on master to advance a # nothing in the tree holds it: no commit has to land on master to advance a
# version, and a release cannot disagree with a package.json someone edited. # version, and a release cannot disagree with a package.json someone edited.
# #
# BUMP is major, minor or patch. VERSION overrides it with a version named # BUMP is major, minor or patch. Writes `version`, `tag`, `released_tag` and
# outright. Writes `version`, `tag` and `released_tag` to $GITHUB_OUTPUT when it # `previous_tag` to $GITHUB_OUTPUT when it is set. `released_tag` is the release
# is set; `released_tag` is the release this one follows, and is empty in a # this one follows, and is the commit a promotion re-cuts. `previous_tag` is how
# repository that has never cut one. # far back the release notes should reach.
set -euo pipefail set -euo pipefail
bump="${BUMP:-patch}" bump="${BUMP:-patch}"
named="${VERSION:-}"
semver='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$'
# Only a stable tag counts as a release. v0.0.1-rc4 is a candidate for 0.0.1, so # Only a stable tag counts as a release. v0.0.1-rc4 is a candidate for 0.0.1, so
# counting a patch off it would skip the very version it was a candidate for. # counting a patch off it would skip the very version it was a candidate for.
# `sort -V` puts 0.10.0 above 0.9.0, which a lexical sort does not, and # `sort -V` puts 0.10.0 above 0.9.0, which a lexical sort does not, and
# `sed -n p` reports no matches as an empty line rather than as the failure # `sed -n p` reports no matches as an empty line rather than as the failure
# `grep` would return under pipefail. # `grep` would return under pipefail.
released="$(git tag -l 'v*' \ releases() { # <sed script selecting the tags to consider>
| sed -n 's/^v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$/\1/p' \ git tag -l 'v*' | sed -n "$1" | sort -V
| sort -V \ }
| tail -1)"
released_tag=""
[ -n "$released" ] && released_tag="v$released"
if [ -n "$named" ]; then stable='s/^v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$/\1/p'
if [[ ! "$named" =~ $semver ]]; then released="$(releases "$stable" | tail -1)"
echo "next-version: '$named' is not a version this releases" >&2 released_tag=""
echo "next-version: a version is MAJOR.MINOR.PATCH with an optional -prerelease, e.g. 0.1.0 or 1.0.0-rc1" >&2 if [ -n "$released" ]; then released_tag="v$released"; fi
exit 1
fi base="${released:-0.0.0}"
version="$named" IFS=. read -r major minor patch <<<"$base"
from="named outright"
else case "$bump" in
base="${released:-0.0.0}" major)
from="a $bump off ${base}" version="$((major + 1)).0.0"
IFS=. read -r major minor patch <<<"$base" level='s/^v\([0-9][0-9]*\.0\.0\)$/\1/p'
case "$bump" in ;;
major) version="$((major + 1)).0.0" ;; minor)
minor) version="$major.$((minor + 1)).0" ;; version="$major.$((minor + 1)).0"
patch) version="$major.$minor.$((patch + 1))" ;; level='s/^v\([0-9][0-9]*\.[0-9][0-9]*\.0\)$/\1/p'
;;
patch)
version="$major.$minor.$((patch + 1))"
level=""
;;
*) *)
echo "next-version: '$bump' is not a bump; use major, minor or patch" >&2 echo "next-version: '$bump' is not a bump; use major, minor or patch" >&2
exit 1 exit 1
;; ;;
esac esac
# A patch follows the release before it, which is what GoReleaser assumes on its
# own, so it is left alone to assume it. A milestone consolidates every patch
# since the last release at its own level, and its notes have to reach back that
# far or they describe the one merge that happened to be last. With nothing at
# that level yet, they reach back to the first release there has ever been.
previous_tag=""
if [ -n "$level" ]; then
previous="$(releases "$level" | tail -1)"
previous="${previous:-$(releases "$stable" | head -1)}"
if [ -n "$previous" ]; then previous_tag="v$previous"; fi
fi fi
tag="v$version" tag="v$version"
# A tag that is already there means this version was already cut. Moving it echo "next-version: releasing $version, a $bump off $base"
# would relabel a release that people have installed. if [ -n "$previous_tag" ]; then
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then echo "next-version: the notes reach back to $previous_tag"
echo "next-version: $tag is already tagged, so there is nothing to release at $version" >&2
exit 1
fi fi
echo "next-version: releasing $version, $from"
if [ -n "${GITHUB_OUTPUT:-}" ]; then if [ -n "${GITHUB_OUTPUT:-}" ]; then
{ {
echo "version=$version" echo "version=$version"
echo "tag=$tag" echo "tag=$tag"
echo "released_tag=$released_tag" echo "released_tag=$released_tag"
echo "previous_tag=$previous_tag"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
fi fi