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
33 changes: 33 additions & 0 deletions .github/scripts/previous-release-tag.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Resolves the nearest reachable prior SemVer release tag from
# <current_tag> (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 <current_tag>" >&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
79 changes: 79 additions & 0 deletions .github/scripts/tests/test-previous-release-tag.sh
Original file line number Diff line number Diff line change
@@ -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 ]
71 changes: 71 additions & 0 deletions .github/scripts/tests/test-validate-release-tag.sh
Original file line number Diff line number Diff line change
@@ -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"
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 ]
49 changes: 49 additions & 0 deletions .github/scripts/validate-release-tag.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Validates a release tag against Cargo.toml's canonical workspace version
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
# (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 <tag> [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'"
11 changes: 10 additions & 1 deletion specs/005-ci-release-versioning/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<tag>`/`Cargo.toml` mismatch fails with an error naming both values
(SC-006), and that re-publishing an existing tag is rejected. Then
Expand All @@ -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 <current_tag>^`.
**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
Expand Down