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:
341
scripts/semver-ci
Executable file
341
scripts/semver-ci
Executable 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
|
||||
Reference in New Issue
Block a user