From b6475e0ad343082432b186de0e4f290237bc034d Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 16 Aug 2026 14:02:09 +0530 Subject: [PATCH] feat(ci): resolve the release version from the tags the repo carries The tags are the record of what has been released, so nothing in the tree holds the version and no commit has to land on master to advance one. Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ --- .github/scripts/next-version-test.sh | 121 +++++++++++++++++++++++++++ .github/scripts/next-version.sh | 63 ++++++++++++++ Makefile | 1 + 3 files changed, 185 insertions(+) create mode 100755 .github/scripts/next-version-test.sh create mode 100755 .github/scripts/next-version.sh diff --git a/.github/scripts/next-version-test.sh b/.github/scripts/next-version-test.sh new file mode 100755 index 0000000..8b201e3 --- /dev/null +++ b/.github/scripts/next-version-test.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Drives next-version.sh against repositories whose tags are planted by hand. +# Run under the flags GitHub Actions uses for a `run:` block, because that is +# where a swallowed failure hides. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +script="$here/next-version.sh" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +failed=0 +outputs="" +status=0 +stderr="" + +resolve() { # ... + local name="$1" bump="$2" version="$3" + shift 3 + local repo="$work/$name" + rm -rf "$repo" + mkdir -p "$repo" + git -C "$repo" init -q + git -C "$repo" -c user.email=t@t -c user.name=t commit -q --allow-empty -m base + local tag + for tag in "$@"; do git -C "$repo" tag "$tag"; done + outputs="$work/$name.out" + stderr="$work/$name.err" + : > "$outputs" + status=0 + (cd "$repo" && BUMP="$bump" VERSION="$version" GITHUB_OUTPUT="$outputs" \ + bash -eo pipefail "$script") >/dev/null 2>"$stderr" || status=$? +} + +fail() { + echo "FAIL: $*" >&2 + failed=1 +} + +expect_version() { # + local want="version=$1" + grep -qxF -- "$want" "$outputs" || fail "$2: resolved $(tr '\n' ' ' <"$outputs"), want $want" + grep -qxF -- "tag=v$1" "$outputs" || fail "$2: tag does not match the version it resolved" + [ "$status" = 0 ] || fail "$2: exit $status, want 0" +} + +expect_refused() { # + [ "$status" != 0 ] || fail "$1: exit 0, want a refusal" + grep -q -- "$2" "$stderr" || fail "$1: refused with '$(cat "$stderr")', want it to mention '$2'" + [ ! -s "$outputs" ] || fail "$1: refused but still wrote an output" +} + +# A repository with nothing released yet starts the line at 0.0.1 rather than +# reissuing 0.0.0. +resolve first patch "" +expect_version 0.0.1 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. +resolve rcs patch "" v0.0.1-rc1 v0.0.1-rc4 +expect_version 0.0.1 rcs + +resolve patch patch "" v1.2.3 +expect_version 1.2.4 patch + +resolve minor minor "" v1.2.3 +expect_version 1.3.0 minor + +resolve major major "" v1.2.3 +expect_version 2.0.0 major + +# 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. +resolve ordering patch "" v0.9.0 v0.10.0 +expect_version 0.10.1 ordering + +# 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 +expect_version 1.2.4 noise + +resolve named "" 2.5.0 v1.2.3 +expect_version 2.5.0 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 +# twice rather than landing on the tag the first one just cut. +resolve consecutive patch "" v1.2.3 v1.2.4 +expect_version 1.2.5 consecutive + +# Naming a version that is already tagged would relabel a release people have +# already installed. +resolve named-already "" 1.2.3 v1.2.3 +expect_refused named-already "is already tagged" + +if [ "$failed" = 0 ]; then + echo "next-version-test: ok" +else + echo "next-version-test: failures above" >&2 + exit 1 +fi diff --git a/.github/scripts/next-version.sh b/.github/scripts/next-version.sh new file mode 100755 index 0000000..763358c --- /dev/null +++ b/.github/scripts/next-version.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Resolves the version a release is cutting. The tags this repo carries are the +# record of what has been released, so the version is counted off them and +# 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. +# +# BUMP is major, minor or patch. VERSION overrides it with a version named +# outright. Writes `version` and `tag` to $GITHUB_OUTPUT when it is set. +set -euo pipefail + +bump="${BUMP:-patch}" +named="${VERSION:-}" + +semver='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$' + +if [ -n "$named" ]; then + if [[ ! "$named" =~ $semver ]]; then + echo "next-version: '$named' is not a version this releases" >&2 + echo "next-version: a version is MAJOR.MINOR.PATCH with an optional -prerelease, e.g. 0.1.0 or 1.0.0-rc1" >&2 + exit 1 + fi + version="$named" + from="named outright" +else + # Only a stable tag is a base to count from. 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. `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 `grep` would return under pipefail. + base="$(git tag -l 'v*' \ + | sed -n 's/^v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$/\1/p' \ + | sort -V \ + | tail -1)" + base="${base:-0.0.0}" + from="a $bump off ${base}" + IFS=. read -r major minor patch <<<"$base" + case "$bump" in + major) version="$((major + 1)).0.0" ;; + minor) version="$major.$((minor + 1)).0" ;; + patch) version="$major.$minor.$((patch + 1))" ;; + *) + echo "next-version: '$bump' is not a bump; use major, minor or patch" >&2 + exit 1 + ;; + esac +fi + +tag="v$version" + +# A tag that is already there means this version was already cut. Moving it +# would relabel a release that people have installed. +if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + echo "next-version: $tag is already tagged, so there is nothing to release at $version" >&2 + exit 1 +fi + +echo "next-version: releasing $version, $from" +if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo "version=$version" + echo "tag=$tag" + } >> "$GITHUB_OUTPUT" +fi diff --git a/Makefile b/Makefile index 6735d52..60badb2 100644 --- a/Makefile +++ b/Makefile @@ -158,6 +158,7 @@ test-folio: test-ci-scripts: .github/scripts/replay-ui-summary-test.sh .github/scripts/folio-run-test.sh + .github/scripts/next-version-test.sh test-spec-api: cd pkg/spec && npm test --silent