#!/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: /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 <}" 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