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