Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
22 changes: 22 additions & 0 deletions .github/scripts/previous-release-tag.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/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 describe --tags --match "v[0-9]*" --abbrev=0 "${current_tag}^" 2>/dev/null || true
75 changes: 75 additions & 0 deletions .github/scripts/tests/test-previous-release-tag.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/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 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" "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 ]
69 changes: 69 additions & 0 deletions .github/scripts/tests/test-validate-release-tag.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/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
if "$@" >/tmp/out.$$ 2>&1; then
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
pass=$((pass + 1))
else
fail=$((fail + 1))
echo "FAIL (expected success): $desc"
cat /tmp/out.$$
fi
rm -f /tmp/out.$$
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

assert_fail() {
local desc="$1"; shift
if "$@" >/tmp/out.$$ 2>&1; then
fail=$((fail + 1))
echo "FAIL (expected failure but succeeded): $desc"
cat /tmp/out.$$
else
pass=$((pass + 1))
fi
rm -f /tmp/out.$$
}

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 ]
48 changes: 48 additions & 0 deletions .github/scripts/validate-release-tag.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/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), and resolves the previous release tag for release-notes
# generation (FR-011). 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 '
/^\[workspace\.package\]/ { in_section = 1; next }
/^\[/ { in_section = 0 }
in_section && /^version[[:space:]]*=/ {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
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