diff --git a/.github/scripts/previous-release-tag.sh b/.github/scripts/previous-release-tag.sh new file mode 100755 index 0000000..47a4715 --- /dev/null +++ b/.github/scripts/previous-release-tag.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Resolves the nearest reachable prior SemVer release tag from +# (FR-011). Prints the previous tag on stdout if one exists; +# prints nothing and exits 0 if this is the first release (FR-009's +# fallback path decides what to do with an empty result -- this script +# only reports the fact). +set -euo pipefail + +usage() { + echo "usage: $0 " >&2 + exit 2 +} + +[ $# -ge 1 ] || usage +current_tag="$1" + +if ! git rev-parse -q --verify "refs/tags/${current_tag}" >/dev/null; then + echo "error: tag '$current_tag' does not exist in this repository" >&2 + exit 1 +fi + +# git's --match is a shell glob, not a regex: "v[0-9]*" also matches +# non-release tags like "v1.1.0-rc1" (any suffix after a leading digit). +# Walk back past any such match until we find one that's strict SemVer +# (vMAJOR.MINOR.PATCH, no suffix), or run out of tags entirely. +ref="${current_tag}^" +while candidate="$(git describe --tags --match "v[0-9]*" --abbrev=0 "$ref" 2>/dev/null)"; do + if [[ "$candidate" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "$candidate" + exit 0 + fi + ref="${candidate}^" +done diff --git a/.github/scripts/tests/test-previous-release-tag.sh b/.github/scripts/tests/test-previous-release-tag.sh new file mode 100755 index 0000000..d50db24 --- /dev/null +++ b/.github/scripts/tests/test-previous-release-tag.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Fixture-based tests for previous-release-tag.sh (T005). Builds a throwaway +# git repo with a known tag history rather than touching the real repo's +# tags. Run manually: +# bash .github/scripts/tests/test-previous-release-tag.sh +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +script="$script_dir/../previous-release-tag.sh" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +pass=0 +fail=0 + +check_eq() { + local desc="$1" expected="$2" actual="$3" + if [ "$expected" == "$actual" ]; then + pass=$((pass + 1)) + else + fail=$((fail + 1)) + echo "FAIL: $desc (expected '$expected', got '$actual')" + fi +} + +assert_fail() { + local desc="$1"; shift + if "$@" >/dev/null 2>&1; then + fail=$((fail + 1)) + echo "FAIL (expected failure but succeeded): $desc" + else + pass=$((pass + 1)) + fi +} + +( + cd "$tmp" + git init -q + git config user.email "test@example.com" + git config user.name "Test" + # Lightweight, unsigned tags regardless of the caller's global git config + # (e.g. tag.gpgsign=true would otherwise force an annotated/signed tag + # here, which fails without an interactive editor or GPG key). + git config tag.gpgsign false + tag() { git -c tag.gpgsign=false tag "$1"; } + + echo "one" >file.txt + git add file.txt + git commit -q -m "first" + tag v1.0.0 + + echo "two" >file.txt + git commit -q -am "second" + tag v1.1.0 + + echo "three" >file.txt + git commit -q -am "third" + tag v1.1.0-rc1 + + echo "four" >file.txt + git commit -q -am "fourth" + tag v2.0.0 +) + +out="$(cd "$tmp" && bash "$script" "v1.1.0")" +check_eq "previous tag for v1.1.0 is v1.0.0" "v1.0.0" "$out" + +out="$(cd "$tmp" && bash "$script" "v2.0.0")" +check_eq "previous tag for v2.0.0 is v1.1.0, not the intervening v1.1.0-rc1 prerelease" "v1.1.0" "$out" + +out="$(cd "$tmp" && bash "$script" "v1.0.0")" +check_eq "first release has no previous tag (empty output, FR-009 fallback)" "" "$out" + +assert_fail "nonexistent tag fails" bash -c "cd '$tmp' && bash '$script' v9.9.9" + +echo "----" +echo "pass=$pass fail=$fail" +[ "$fail" -eq 0 ] diff --git a/.github/scripts/tests/test-validate-release-tag.sh b/.github/scripts/tests/test-validate-release-tag.sh new file mode 100755 index 0000000..fed0759 --- /dev/null +++ b/.github/scripts/tests/test-validate-release-tag.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Fixture-based tests for validate-release-tag.sh (T005, Constitution I: +# written before/alongside the script, not after). Run manually: +# bash .github/scripts/tests/test-validate-release-tag.sh +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +script="$script_dir/../validate-release-tag.sh" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +pass=0 +fail=0 + +assert_ok() { + local desc="$1"; shift + local capture; capture="$(mktemp "$tmp/out.XXXXXX")" + if "$@" >"$capture" 2>&1; then + pass=$((pass + 1)) + else + fail=$((fail + 1)) + echo "FAIL (expected success): $desc" + cat "$capture" + fi + rm -f "$capture" +} + +assert_fail() { + local desc="$1"; shift + local capture; capture="$(mktemp "$tmp/out.XXXXXX")" + if "$@" >"$capture" 2>&1; then + fail=$((fail + 1)) + echo "FAIL (expected failure but succeeded): $desc" + cat "$capture" + else + pass=$((pass + 1)) + fi + rm -f "$capture" +} + +cargo_toml="$tmp/Cargo.toml" +cat >"$cargo_toml" <<'EOF' +[workspace] +members = ["crates/*"] + +[workspace.package] +version = "1.2.3" +edition = "2021" +EOF + +assert_ok "matching tag passes" bash "$script" "v1.2.3" "$cargo_toml" +assert_fail "mismatched patch fails" bash "$script" "v1.2.4" "$cargo_toml" +assert_fail "mismatched minor fails" bash "$script" "v1.3.3" "$cargo_toml" +assert_fail "missing v prefix fails" bash "$script" "1.2.3" "$cargo_toml" +assert_fail "non-semver tag fails" bash "$script" "v1.2" "$cargo_toml" +assert_fail "pre-release suffix fails (out of scope per spec.md Assumptions)" bash "$script" "v1.2.3-rc1" "$cargo_toml" +assert_fail "missing Cargo.toml fails" bash "$script" "v1.2.3" "$tmp/does-not-exist.toml" + +# Mismatch error message must name both values (SC-006). +out="$(bash "$script" "v9.9.9" "$cargo_toml" 2>&1 || true)" +if [[ "$out" == *"v9.9.9"* && "$out" == *"1.2.3"* ]]; then + pass=$((pass + 1)) +else + fail=$((fail + 1)) + echo "FAIL: mismatch error must name both tag and workspace version, got: $out" +fi + +echo "----" +echo "pass=$pass fail=$fail" +[ "$fail" -eq 0 ] diff --git a/.github/scripts/validate-release-tag.sh b/.github/scripts/validate-release-tag.sh new file mode 100755 index 0000000..84d7b31 --- /dev/null +++ b/.github/scripts/validate-release-tag.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Validates a release tag against Cargo.toml's canonical workspace version +# (FR-010). Previous-release-tag resolution (FR-011) is a separate concern, +# implemented in previous-release-tag.sh. See +# specs/005-ci-release-versioning/spec.md. +set -euo pipefail + +usage() { + echo "usage: $0 [cargo_toml_path]" >&2 + exit 2 +} + +[ $# -ge 1 ] || usage +tag="$1" +cargo_toml="${2:-Cargo.toml}" + +if [ ! -f "$cargo_toml" ]; then + echo "error: $cargo_toml not found" >&2 + exit 1 +fi + +if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "error: tag '$tag' is not a valid SemVer release tag (expected vMAJOR.MINOR.PATCH)" >&2 + exit 1 +fi + +workspace_version="$(awk ' + /^[[:space:]]*\[workspace\.package\]/ { in_section = 1; next } + /^[[:space:]]*\[/ { in_section = 0 } + in_section && /^[[:space:]]*version[[:space:]]*=/ { + match($0, /"[^"]+"/) + print substr($0, RSTART + 1, RLENGTH - 2) + exit + } +' "$cargo_toml")" + +if [ -z "$workspace_version" ]; then + echo "error: could not find [workspace.package].version in $cargo_toml" >&2 + exit 1 +fi + +expected_tag="v${workspace_version}" + +if [ "$tag" != "$expected_tag" ]; then + echo "error: tag/version mismatch: tag is '$tag', but [workspace.package].version is '$workspace_version' (expected tag '$expected_tag')" >&2 + exit 1 +fi + +echo "ok: tag '$tag' matches workspace version '$workspace_version'" diff --git a/specs/005-ci-release-versioning/tasks.md b/specs/005-ci-release-versioning/tasks.md index 4b57293..5f871b1 100644 --- a/specs/005-ci-release-versioning/tasks.md +++ b/specs/005-ci-release-versioning/tasks.md @@ -86,7 +86,7 @@ shared by both release publishing (US2) and versioning/notes (US3). **CRITICAL**: No US2/US3 task starts before this phase is complete. -- [ ] **T005** [Test-first, per Constitution I] Write a fixture-based test +- [x] **T005** [Test-first, per Constitution I] Write a fixture-based test for version/tag validation *before* implementing it: asserts a `v`/`Cargo.toml` mismatch fails with an error naming both values (SC-006), and that re-publishing an existing tag is rejected. Then @@ -96,6 +96,15 @@ shared by both release publishing (US2) and versioning/notes (US3). Also implements `previous_release_tag` selection (FR-011): nearest reachable prior SemVer tag via `git describe --tags --match "v[0-9]*" --abbrev=0 ^`. + **Done 2026-07-30**: `.github/scripts/validate-release-tag.sh` (tag + format + version match, SC-006 error message) and + `.github/scripts/previous-release-tag.sh` (FR-011), each with fixture + tests under `.github/scripts/tests/` run against a throwaway git repo + fixture (not the real repo's tags). Re-publishing-an-existing-tag + rejection is **not** in these scripts — it needs to check the real + GitHub Release API (does a release already exist for this tag?), which + is T012's concern in `release.yml`, not something these offline, + git-only scripts can determine. - [ ] **T006** Implement build-identifier computation: deterministic `GITHUB_RUN_NUMBER.GITHUB_RUN_ATTEMPT` (FR-005), evaluated numerically as `(GITHUB_RUN_NUMBER, GITHUB_RUN_ATTEMPT)` for "strictly increasing" checks