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>
342 lines
9.8 KiB
Bash
Executable File
342 lines
9.8 KiB
Bash
Executable File
#!/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
|