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:
Paul O'Reilly
2026-04-17 19:29:40 +12:00
parent 340c40392a
commit cde811fa0e
6 changed files with 1264 additions and 0 deletions

341
scripts/semver-ci Executable file
View File

@@ -0,0 +1,341 @@
#!/usr/bin/env bash
set -uo pipefail
usage() {
cat <<'EOF'
Usage: semver-ci [OPTIONS]
Compute the next MAJOR.MINOR.PATCH.BUILD from commit-message tokens since the
last release, maintain a VERSION.md file, and optionally tag / push / create a
Gitea release. Non-main branches get MAJOR.MINOR.PATCH.BUILD-BRANCH-SHORTSHA
and never produce a release.
Options:
-n, --dryrun Preview actions without side effects
--tag Create the tag locally (main-only)
--push Push the tag to origin (implies --tag, main-only)
--release Create Gitea release via API (implies --push, main-only)
--build N Override BUILD number
--base TAG Override last-release tag auto-detection
--branch NAME Override current-branch detection
--main-branch NAME Name of the main branch (default: main)
--version-file PATH Path to the version file (default: <repo>/VERSION.md)
--no-version-file Skip writing the version file
-h, --help Show this help
Environment (read when present):
GITHUB_RUN_NUMBER / GITEA_RUN_NUMBER BUILD source
GITHUB_REF_NAME Branch name
GITHUB_SHA Full commit SHA
GITHUB_OUTPUT Step outputs file (append-mode)
GITEA_TOKEN / GITHUB_TOKEN Auth for --release
GITHUB_SERVER_URL / GITHUB_REPOSITORY Target server and repo for --release
EOF
}
dryrun=false
do_tag=false
do_push=false
do_release=false
build_override=""
base_override=""
branch_override=""
main_branch="main"
version_file_override=""
write_version_file=true
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-n|--dryrun) dryrun=true; shift ;;
--tag) do_tag=true; shift ;;
--push) do_tag=true; do_push=true; shift ;;
--release) do_tag=true; do_push=true; do_release=true; shift ;;
--build)
[[ $# -lt 2 ]] && { echo "Error: --build requires a value" >&2; exit 1; }
build_override="$2"; shift 2 ;;
--base)
[[ $# -lt 2 ]] && { echo "Error: --base requires a value" >&2; exit 1; }
base_override="$2"; shift 2 ;;
--branch)
[[ $# -lt 2 ]] && { echo "Error: --branch requires a value" >&2; exit 1; }
branch_override="$2"; shift 2 ;;
--main-branch)
[[ $# -lt 2 ]] && { echo "Error: --main-branch requires a value" >&2; exit 1; }
main_branch="$2"; shift 2 ;;
--version-file)
[[ $# -lt 2 ]] && { echo "Error: --version-file requires a value" >&2; exit 1; }
version_file_override="$2"; shift 2 ;;
--no-version-file) write_version_file=false; shift ;;
-*) echo "Error: Unknown option: $1" >&2; exit 1 ;;
*) echo "Error: Unexpected argument: $1" >&2; exit 1 ;;
esac
done
# --- git repo sanity ---
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "Error: not inside a git repository" >&2
exit 1
fi
if ! git rev-parse --verify HEAD >/dev/null 2>&1; then
echo "Error: repository has no commits" >&2
exit 1
fi
repo_root="$(git rev-parse --show-toplevel)"
# --- branch & commit detection ---
if [[ -n "$branch_override" ]]; then
branch="$branch_override"
elif [[ -n "${GITHUB_REF_NAME:-}" ]]; then
branch="$GITHUB_REF_NAME"
else
branch="$(git rev-parse --abbrev-ref HEAD)"
fi
if [[ -n "${GITHUB_SHA:-}" ]]; then
short_sha="${GITHUB_SHA:0:7}"
else
short_sha="$(git rev-parse --short=7 HEAD)"
fi
is_main=false
if [[ "$branch" == "$main_branch" ]]; then
is_main=true
fi
# --- last release tag discovery ---
TAG_RE='^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'
if [[ -n "$base_override" ]]; then
if ! git rev-parse --verify "$base_override" >/dev/null 2>&1; then
echo "Error: --base tag does not exist: $base_override" >&2
exit 1
fi
last_tag="$base_override"
else
last_tag="$(git tag -l 'v*' | grep -E "$TAG_RE" | sort -V | tail -n 1 || true)"
fi
if [[ -z "$last_tag" ]]; then
maj=0; min=0; pat=0
log_range="HEAD"
else
ver="${last_tag#v}"
IFS='.' read -r maj min pat _build <<< "$ver"
log_range="${last_tag}..HEAD"
fi
# --- commit scanning ---
commits="$(git log "$log_range" --format=%B 2>/dev/null || true)"
commit_count="$(git log "$log_range" --oneline 2>/dev/null | wc -l | tr -d ' ' || true)"
commit_count="${commit_count:-0}"
count_trigger() {
local token="$1"
# Whole-line match, whitespace-tolerant; count matching lines.
printf '%s\n' "$commits" | grep -cE "^[[:space:]]*${token}[[:space:]]*\$" || true
}
count_major="$(count_trigger NEW_MAJOR)"
count_minor="$(count_trigger NEW_MINOR)"
count_patch="$(count_trigger NEW_PATCH)"
bump="none"
released=false
if [[ "$count_major" -gt 0 ]]; then
maj=$((maj + 1)); min=0; pat=0
bump="MAJOR"; released=true
elif [[ "$count_minor" -gt 0 ]]; then
min=$((min + 1)); pat=0
bump="MINOR"; released=true
elif [[ "$count_patch" -gt 0 ]]; then
pat=$((pat + 1))
bump="PATCH"; released=true
fi
# --- BUILD number ---
if [[ -n "$build_override" ]]; then
build="$build_override"
elif [[ -n "${GITHUB_RUN_NUMBER:-}" ]]; then
build="$GITHUB_RUN_NUMBER"
elif [[ -n "${GITEA_RUN_NUMBER:-}" ]]; then
build="$GITEA_RUN_NUMBER"
else
build="$(git rev-list --count HEAD 2>/dev/null || echo "")"
fi
if [[ -z "$build" ]]; then
echo "Error: Cannot determine BUILD number. Set GITHUB_RUN_NUMBER or pass --build N." >&2
exit 1
fi
if ! [[ "$build" =~ ^[0-9]+$ ]]; then
echo "Error: BUILD number must be a non-negative integer, got: $build" >&2
exit 1
fi
# --- compose version ---
if $is_main; then
version="${maj}.${min}.${pat}.${build}"
new_tag="v${version}"
else
safe_branch="${branch//\//-}"
version="${maj}.${min}.${pat}.${build}-${safe_branch}-${short_sha}"
new_tag=""
fi
# --- version file path ---
if [[ -n "$version_file_override" ]]; then
version_file="$version_file_override"
else
version_file="${repo_root}/VERSION.md"
fi
generated="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
render_version_file() {
cat <<EOF
# Version
**${version}**
- Major: ${maj}
- Minor: ${min}
- Patch: ${pat}
- Build: ${build}
- Branch: ${branch}
- Commit: ${short_sha}
- Tag: ${new_tag:--}
- Generated: ${generated}
EOF
}
# --- dryrun output ---
if $dryrun; then
if $is_main; then
echo "[dryrun] Branch: ${branch} (main-branch detected)"
else
echo "[dryrun] Branch: ${branch} (non-main)"
fi
echo "[dryrun] Commit: ${short_sha}"
echo "[dryrun] Last release: ${last_tag:-<none>}"
echo "[dryrun] Commits scanned: ${commit_count}"
triggers=()
[[ "$count_major" -gt 0 ]] && triggers+=("NEW_MAJOR (${count_major})")
[[ "$count_minor" -gt 0 ]] && triggers+=("NEW_MINOR (${count_minor})")
[[ "$count_patch" -gt 0 ]] && triggers+=("NEW_PATCH (${count_patch})")
if [[ ${#triggers[@]} -eq 0 ]]; then
echo "[dryrun] Triggers found: none"
else
echo "[dryrun] Triggers found: $(IFS=', '; echo "${triggers[*]}")"
fi
echo "[dryrun] Version bump: ${bump}"
echo "[dryrun] New version: ${version}"
if $write_version_file; then
echo "[dryrun] Would write version file: ${version_file}"
fi
if $is_main; then
$do_tag && echo "[dryrun] Would create tag: ${new_tag}"
$do_push && echo "[dryrun] Would push tag to origin"
if $do_release; then
if $released; then
echo "[dryrun] Would create Gitea release for ${new_tag}"
else
echo "[dryrun] Would skip release (no MAJOR/MINOR/PATCH change)"
fi
fi
else
if $do_tag || $do_push || $do_release; then
echo "[dryrun] Would skip tag/push/release on non-main branch"
fi
fi
exit 0
fi
# --- real mode ---
echo "$version"
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
{
echo "version=${version}"
echo "tag=${new_tag}"
echo "released=${released}"
echo "branch=${branch}"
echo "is_main=${is_main}"
} >> "$GITHUB_OUTPUT"
fi
if $write_version_file; then
vf_dir="$(dirname "$version_file")"
if [[ ! -d "$vf_dir" ]]; then
echo "Error: version file directory does not exist: $vf_dir" >&2
exit 1
fi
render_version_file > "$version_file"
fi
if ! $is_main; then
if $do_tag || $do_push || $do_release; then
echo "semver-ci: skipping tag/push/release on non-main branch '${branch}'" >&2
fi
exit 0
fi
if $do_tag; then
if git rev-parse -q --verify "refs/tags/${new_tag}" >/dev/null; then
echo "Error: tag already exists: ${new_tag}" >&2
exit 1
fi
git tag "$new_tag"
fi
if $do_push; then
git push origin "$new_tag"
fi
if $do_release; then
if ! $released; then
echo "semver-ci: release skipped (no MAJOR/MINOR/PATCH change)" >&2
exit 0
fi
token="${GITEA_TOKEN:-${GITHUB_TOKEN:-}}"
if [[ -z "$token" ]]; then
echo "Error: GITEA_TOKEN or GITHUB_TOKEN required for --release" >&2
exit 1
fi
server="${GITHUB_SERVER_URL:-}"
repo="${GITHUB_REPOSITORY:-}"
if [[ -z "$server" || -z "$repo" ]]; then
echo "Error: GITHUB_SERVER_URL and GITHUB_REPOSITORY must be set for --release" >&2
exit 1
fi
api_url="${server%/}/api/v1/repos/${repo}/releases"
body="$(printf '{"tag_name":"%s","name":"%s","body":"Automated release"}' "$new_tag" "$new_tag")"
tmp_response="$(mktemp)"
trap 'rm -f "$tmp_response"' EXIT
http_code="$(curl -sS -o "$tmp_response" -w '%{http_code}' \
-X POST "$api_url" \
-H "Authorization: token ${token}" \
-H "Content-Type: application/json" \
-d "$body")" || { echo "Error: curl failed" >&2; exit 1; }
if [[ "$http_code" != "201" ]]; then
echo "Error: Gitea release creation failed (HTTP ${http_code})" >&2
cat "$tmp_response" >&2
exit 1
fi
fi