Add semver-ci: commit-driven semantic versioning for Gitea Actions
Computes MAJOR.MINOR.PATCH.BUILD from NEW_MAJOR/NEW_MINOR/NEW_PATCH whole-line tokens in commit messages since the last release tag. Non-main branches get a BRANCH-SHORTSHA suffix and never release. Maintains VERSION.md, writes $GITHUB_OUTPUT, and optionally creates a Gitea release via API. Includes spec, 67-assertion test, usage guide (docs/semver-ci.md), and a drop-in Gitea Actions workflow template. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
439
tests/test-semver-ci.sh
Executable file
439
tests/test-semver-ci.sh
Executable file
@@ -0,0 +1,439 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SEMVER="$SCRIPT_DIR/scripts/semver-ci"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m'
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
assert_eq() {
|
||||
local desc="$1" expected="$2" actual="$3"
|
||||
if [[ "$expected" == "$actual" ]]; then
|
||||
echo -e "${GREEN}PASS${NC}: $desc"
|
||||
((pass++)) || true
|
||||
else
|
||||
echo -e "${RED}FAIL${NC}: $desc"
|
||||
echo " expected: $expected"
|
||||
echo " actual: $actual"
|
||||
((fail++)) || true
|
||||
fi
|
||||
}
|
||||
|
||||
assert_match() {
|
||||
local desc="$1" pattern="$2" actual="$3"
|
||||
if [[ "$actual" =~ $pattern ]]; then
|
||||
echo -e "${GREEN}PASS${NC}: $desc"
|
||||
((pass++)) || true
|
||||
else
|
||||
echo -e "${RED}FAIL${NC}: $desc"
|
||||
echo " pattern: $pattern"
|
||||
echo " actual: $actual"
|
||||
((fail++)) || true
|
||||
fi
|
||||
}
|
||||
|
||||
assert_no_match() {
|
||||
local desc="$1" pattern="$2" actual="$3"
|
||||
if [[ ! "$actual" =~ $pattern ]]; then
|
||||
echo -e "${GREEN}PASS${NC}: $desc"
|
||||
((pass++)) || true
|
||||
else
|
||||
echo -e "${RED}FAIL${NC}: $desc"
|
||||
echo " should not match: $pattern"
|
||||
echo " actual: $actual"
|
||||
((fail++)) || true
|
||||
fi
|
||||
}
|
||||
|
||||
assert_exit() {
|
||||
local desc="$1" expected="$2" actual="$3"
|
||||
if [[ "$expected" -eq "$actual" ]]; then
|
||||
echo -e "${GREEN}PASS${NC}: $desc"
|
||||
((pass++)) || true
|
||||
else
|
||||
echo -e "${RED}FAIL${NC}: $desc"
|
||||
echo " expected exit: $expected"
|
||||
echo " actual exit: $actual"
|
||||
((fail++)) || true
|
||||
fi
|
||||
}
|
||||
|
||||
make_repo() {
|
||||
local dir="$1"
|
||||
mkdir -p "$dir"
|
||||
(
|
||||
cd "$dir"
|
||||
git init -q -b main
|
||||
git config user.email "t@t"
|
||||
git config user.name "t"
|
||||
git commit --allow-empty -q -m "init"
|
||||
)
|
||||
}
|
||||
|
||||
commit_msg() {
|
||||
local dir="$1" msg="$2"
|
||||
(cd "$dir" && git commit --allow-empty -q -m "$msg")
|
||||
}
|
||||
|
||||
tag_at() {
|
||||
local dir="$1" tag="$2"
|
||||
(cd "$dir" && git tag "$tag")
|
||||
}
|
||||
|
||||
run_in() {
|
||||
local dir="$1"; shift
|
||||
(cd "$dir" && "$SEMVER" "$@" 2>&1)
|
||||
}
|
||||
|
||||
echo "=== semver-ci tests ==="
|
||||
echo
|
||||
|
||||
# --- Help ---
|
||||
|
||||
out=$("$SEMVER" --help 2>&1)
|
||||
rc=$?
|
||||
assert_exit "help exits 0" 0 "$rc"
|
||||
assert_match "help mentions BUILD" "BUILD" "$out"
|
||||
assert_match "help mentions VERSION.md" "VERSION.md" "$out"
|
||||
|
||||
# --- Outside git repo ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
out=$(cd "$tmp" && "$SEMVER" --dryrun 2>&1)
|
||||
rc=$?
|
||||
assert_exit "outside git repo exits 1" 1 "$rc"
|
||||
assert_match "outside git repo error" "not inside a git repository" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- Fresh repo, no triggers (main branch) ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
out=$(run_in "$tmp" --dryrun --build 42)
|
||||
assert_match "fresh repo: version 0.0.0.42" "New version: 0\.0\.0\.42" "$out"
|
||||
assert_match "fresh repo: bump none" "Version bump: none" "$out"
|
||||
assert_match "fresh repo: triggers none" "Triggers found: none" "$out"
|
||||
assert_match "fresh repo: last release none" "Last release: <none>" "$out"
|
||||
assert_match "fresh repo: branch main detected" "Branch: main \(main-branch detected\)" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- NEW_PATCH triggers patch bump ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
commit_msg "$tmp" $'fix: stuff\n\nNEW_PATCH'
|
||||
out=$(run_in "$tmp" --dryrun --build 10)
|
||||
assert_match "NEW_PATCH: version 0.0.1.10" "New version: 0\.0\.1\.10" "$out"
|
||||
assert_match "NEW_PATCH: bump PATCH" "Version bump: PATCH" "$out"
|
||||
assert_match "NEW_PATCH: trigger counted" "NEW_PATCH \(1\)" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- NEW_MINOR triggers minor bump and resets patch ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
commit_msg "$tmp" $'feat: thing\n\nNEW_MINOR'
|
||||
out=$(run_in "$tmp" --dryrun --build 10)
|
||||
assert_match "NEW_MINOR: version 0.1.0.10" "New version: 0\.1\.0\.10" "$out"
|
||||
assert_match "NEW_MINOR: bump MINOR" "Version bump: MINOR" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- NEW_MAJOR triggers major bump and resets minor/patch ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
commit_msg "$tmp" $'feat!: breaking\n\nNEW_MAJOR'
|
||||
out=$(run_in "$tmp" --dryrun --build 10)
|
||||
assert_match "NEW_MAJOR: version 1.0.0.10" "New version: 1\.0\.0\.10" "$out"
|
||||
assert_match "NEW_MAJOR: bump MAJOR" "Version bump: MAJOR" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- Precedence: MAJOR > MINOR > PATCH ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
commit_msg "$tmp" $'mixed\n\nNEW_MINOR\nNEW_PATCH'
|
||||
out=$(run_in "$tmp" --dryrun --build 1)
|
||||
assert_match "minor beats patch" "Version bump: MINOR" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
commit_msg "$tmp" $'big\n\nNEW_MAJOR\nNEW_MINOR\nNEW_PATCH'
|
||||
out=$(run_in "$tmp" --dryrun --build 1)
|
||||
assert_match "major beats minor and patch" "Version bump: MAJOR" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- Inline mentions do NOT trigger ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
commit_msg "$tmp" "refactor: mention NEW_PATCH inline but not alone"
|
||||
out=$(run_in "$tmp" --dryrun --build 1)
|
||||
assert_match "inline NEW_PATCH ignored: bump none" "Version bump: none" "$out"
|
||||
assert_match "inline NEW_PATCH ignored: triggers none" "Triggers found: none" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- Whitespace-tolerant triggers ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
commit_msg "$tmp" $'spaced\n\n NEW_PATCH '
|
||||
out=$(run_in "$tmp" --dryrun --build 1)
|
||||
assert_match "whitespace-padded trigger counts" "Version bump: PATCH" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- Base from prior tag: PATCH bump ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
commit_msg "$tmp" $'seed\n\nNEW_MINOR'
|
||||
tag_at "$tmp" "v1.2.3.50"
|
||||
commit_msg "$tmp" $'hotfix\n\nNEW_PATCH'
|
||||
out=$(run_in "$tmp" --dryrun --build 88)
|
||||
assert_match "prior v1.2.3.50 + NEW_PATCH = 1.2.4.88" "New version: 1\.2\.4\.88" "$out"
|
||||
assert_match "prior tag detected" "Last release: v1\.2\.3\.50" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- MAJOR bump resets MINOR/PATCH from prior tag ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
commit_msg "$tmp" "seed"
|
||||
tag_at "$tmp" "v3.5.7.99"
|
||||
commit_msg "$tmp" $'breaking\n\nNEW_MAJOR'
|
||||
out=$(run_in "$tmp" --dryrun --build 100)
|
||||
assert_match "prior v3.5.7.99 + NEW_MAJOR = 4.0.0.100" "New version: 4\.0\.0\.100" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- No triggers, prior tag: M/m/p unchanged ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
commit_msg "$tmp" "seed"
|
||||
tag_at "$tmp" "v2.4.8.60"
|
||||
commit_msg "$tmp" "chore: tidy"
|
||||
out=$(run_in "$tmp" --dryrun --build 77)
|
||||
assert_match "no trigger: reuses M/m/p" "New version: 2\.4\.8\.77" "$out"
|
||||
assert_match "no trigger: bump none" "Version bump: none" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- Tag selection: latest by -V ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
commit_msg "$tmp" "c1"; tag_at "$tmp" "v1.0.0.1"
|
||||
commit_msg "$tmp" "c2"; tag_at "$tmp" "v10.2.0.5" # 10 > 2 numerically
|
||||
commit_msg "$tmp" "c3"; tag_at "$tmp" "v2.9.9.9"
|
||||
commit_msg "$tmp" $'next\n\nNEW_PATCH'
|
||||
out=$(run_in "$tmp" --dryrun --build 1)
|
||||
assert_match "sort -V picks v10.2.0.5 as latest" "Last release: v10\.2\.0\.5" "$out"
|
||||
assert_match "bump from v10.2.0.5" "New version: 10\.2\.1\.1" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- --base overrides detection ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
commit_msg "$tmp" "c1"; tag_at "$tmp" "v5.0.0.1"
|
||||
commit_msg "$tmp" "c2"; tag_at "$tmp" "v6.0.0.2"
|
||||
commit_msg "$tmp" $'n\n\nNEW_PATCH'
|
||||
out=$(run_in "$tmp" --dryrun --build 9 --base v5.0.0.1)
|
||||
assert_match "--base honoured" "Last release: v5\.0\.0\.1" "$out"
|
||||
assert_match "--base affects bump base" "New version: 5\.0\.1\.9" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- --base to nonexistent tag errors ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
out=$(run_in "$tmp" --dryrun --base v99.99.99.99 2>&1)
|
||||
rc=$?
|
||||
assert_exit "--base nonexistent exits 1" 1 "$rc"
|
||||
assert_match "--base nonexistent error" "does not exist" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- --build validation ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
out=$(run_in "$tmp" --dryrun --build abc 2>&1)
|
||||
rc=$?
|
||||
assert_exit "--build non-numeric exits 1" 1 "$rc"
|
||||
assert_match "--build non-numeric error" "non-negative integer" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- Env-based BUILD ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
out=$(cd "$tmp" && GITHUB_RUN_NUMBER=555 "$SEMVER" --dryrun 2>&1)
|
||||
assert_match "GITHUB_RUN_NUMBER used for BUILD" "New version: 0\.0\.0\.555" "$out"
|
||||
|
||||
out=$(cd "$tmp" && GITEA_RUN_NUMBER=777 "$SEMVER" --dryrun 2>&1)
|
||||
assert_match "GITEA_RUN_NUMBER used for BUILD" "New version: 0\.0\.0\.777" "$out"
|
||||
|
||||
out=$(cd "$tmp" && GITHUB_RUN_NUMBER=100 GITEA_RUN_NUMBER=200 "$SEMVER" --dryrun 2>&1)
|
||||
assert_match "GITHUB_RUN_NUMBER preferred over GITEA_RUN_NUMBER" "New version: 0\.0\.0\.100" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- Branch build: non-main ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
commit_msg "$tmp" "seed"; tag_at "$tmp" "v1.2.3.10"
|
||||
(cd "$tmp" && git checkout -q -b feature/login)
|
||||
commit_msg "$tmp" $'wip\n\nNEW_PATCH'
|
||||
out=$(run_in "$tmp" --dryrun --build 77)
|
||||
assert_match "non-main version has branch+sha suffix" "New version: 1\.2\.4\.77-feature-login-[0-9a-f]{7}" "$out"
|
||||
assert_match "non-main shows non-main label" "Branch: feature/login \(non-main\)" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- --branch override ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
out=$(run_in "$tmp" --dryrun --build 5 --branch bugfix/oops)
|
||||
assert_match "--branch override labelled non-main" "Branch: bugfix/oops \(non-main\)" "$out"
|
||||
assert_match "--branch override in version" "New version: 0\.0\.0\.5-bugfix-oops-[0-9a-f]{7}" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- --main-branch override ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
(cd "$tmp" && git checkout -q -b trunk)
|
||||
out=$(run_in "$tmp" --dryrun --build 1 --main-branch trunk)
|
||||
assert_match "--main-branch override treats trunk as main" "Branch: trunk \(main-branch detected\)" "$out"
|
||||
assert_no_match "--main-branch override: no branch suffix" "\-trunk\-" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- Non-main skips tag/push/release in dryrun ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
(cd "$tmp" && git checkout -q -b feature/x)
|
||||
commit_msg "$tmp" $'wip\n\nNEW_PATCH'
|
||||
out=$(run_in "$tmp" --dryrun --release --build 1)
|
||||
assert_match "non-main with --release: dryrun skip message" "Would skip tag/push/release on non-main branch" "$out"
|
||||
assert_no_match "non-main with --release: no create tag line" "Would create tag" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- Real mode: version file written ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
commit_msg "$tmp" $'feat\n\nNEW_MINOR'
|
||||
out=$(run_in "$tmp" --build 42)
|
||||
rc=$?
|
||||
assert_exit "real-mode exits 0" 0 "$rc"
|
||||
assert_eq "stdout is just the version" "0.1.0.42" "$out"
|
||||
assert_match "VERSION.md created" "0.1.0.42" "$(cat "$tmp/VERSION.md")"
|
||||
assert_match "VERSION.md has Major line" "Major: 0" "$(cat "$tmp/VERSION.md")"
|
||||
assert_match "VERSION.md has Build line" "Build: 42" "$(cat "$tmp/VERSION.md")"
|
||||
assert_match "VERSION.md has Tag line" "Tag: v0.1.0.42" "$(cat "$tmp/VERSION.md")"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- Real mode: non-main VERSION.md shows branch suffix and no tag ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
(cd "$tmp" && git checkout -q -b dev/x)
|
||||
commit_msg "$tmp" $'wip\n\nNEW_PATCH'
|
||||
out=$(run_in "$tmp" --build 7)
|
||||
assert_match "non-main stdout has suffix" "0\.0\.1\.7-dev-x-[0-9a-f]{7}" "$out"
|
||||
assert_match "non-main VERSION.md Tag is dash" "Tag: -" "$(cat "$tmp/VERSION.md")"
|
||||
assert_match "non-main VERSION.md branch preserved" "Branch: dev/x" "$(cat "$tmp/VERSION.md")"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- --no-version-file skips file write ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
run_in "$tmp" --build 1 --no-version-file >/dev/null
|
||||
if [[ ! -e "$tmp/VERSION.md" ]]; then
|
||||
echo -e "${GREEN}PASS${NC}: --no-version-file skips write"
|
||||
((pass++)) || true
|
||||
else
|
||||
echo -e "${RED}FAIL${NC}: --no-version-file skips write"
|
||||
((fail++)) || true
|
||||
fi
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- Real mode: --tag creates the tag on main ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
commit_msg "$tmp" $'feat\n\nNEW_PATCH'
|
||||
run_in "$tmp" --tag --build 3 >/dev/null
|
||||
tag_present=$(cd "$tmp" && git tag -l "v0.0.1.3")
|
||||
assert_eq "--tag on main creates tag" "v0.0.1.3" "$tag_present"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- Real mode: --tag is skipped on non-main ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
(cd "$tmp" && git checkout -q -b feature/a)
|
||||
commit_msg "$tmp" $'w\n\nNEW_PATCH'
|
||||
run_in "$tmp" --tag --build 3 >/dev/null 2>&1
|
||||
tag_present=$(cd "$tmp" && git tag -l "v0.0.1.3")
|
||||
assert_eq "--tag on non-main does NOT create tag" "" "$tag_present"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- Tag conflict on main errors ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
commit_msg "$tmp" $'feat\n\nNEW_PATCH'
|
||||
tag_at "$tmp" "v0.0.1.3"
|
||||
out=$(run_in "$tmp" --tag --build 3 2>&1)
|
||||
rc=$?
|
||||
assert_exit "tag conflict exits 1" 1 "$rc"
|
||||
assert_match "tag conflict error message" "tag already exists" "$out"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- GITHUB_OUTPUT is populated ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
commit_msg "$tmp" $'feat\n\nNEW_MINOR'
|
||||
gh_out="$tmp/.outputs"
|
||||
: > "$gh_out"
|
||||
(cd "$tmp" && GITHUB_OUTPUT="$gh_out" "$SEMVER" --build 9 >/dev/null)
|
||||
content="$(cat "$gh_out")"
|
||||
assert_match "GITHUB_OUTPUT has version" "version=0\.1\.0\.9" "$content"
|
||||
assert_match "GITHUB_OUTPUT has tag" "tag=v0\.1\.0\.9" "$content"
|
||||
assert_match "GITHUB_OUTPUT has released=true" "released=true" "$content"
|
||||
assert_match "GITHUB_OUTPUT has branch=main" "branch=main" "$content"
|
||||
assert_match "GITHUB_OUTPUT has is_main=true" "is_main=true" "$content"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- GITHUB_OUTPUT on non-main: tag empty, is_main false ---
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
make_repo "$tmp"
|
||||
(cd "$tmp" && git checkout -q -b feature/z)
|
||||
commit_msg "$tmp" $'w\n\nNEW_PATCH'
|
||||
gh_out="$tmp/.outputs"
|
||||
: > "$gh_out"
|
||||
(cd "$tmp" && GITHUB_OUTPUT="$gh_out" "$SEMVER" --build 4 >/dev/null)
|
||||
content="$(cat "$gh_out")"
|
||||
assert_match "non-main GITHUB_OUTPUT: is_main=false" "is_main=false" "$content"
|
||||
assert_match "non-main GITHUB_OUTPUT: tag empty" "^tag=$" "$(printf '%s\n' "$content" | grep '^tag=')"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# --- Summary ---
|
||||
|
||||
echo
|
||||
total=$((pass + fail))
|
||||
echo -e "Results: ${GREEN}${pass}${NC}/${total} passed"
|
||||
if [[ $fail -gt 0 ]]; then
|
||||
echo -e "${RED}${fail} test(s) failed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user