#!/usr/bin/env bash set -euo pipefail usage() { cat <<'EOF' Usage: version.sh [-C GIT_DIR] [--docker] [--version VER] Print one line: the canonical artifact version (Debian Version, no leading v). With --docker, print the Docker tag rendering of that version. Derivation (builder contract «版本号»): official HEAD exact-match of vX.Y.Z → X.Y.Z test nearest ancestor stable tag vX.Y.Z → X.Y.(Z+1)~YYYYMMDD.branch.n+gSHA no tag no stable tag reachable from HEAD → 0.0.0~YYYYMMDD.branch+gSHA YYYYMMDD is the build date. SOURCE_DATE_EPOCH, if set, is that instant in UTC; otherwise the local calendar date. Official X.Y.Z never gets a date. Stable tags match v.. only. Baseline is ancestry, not the highest version in the repository. This script does not fetch tags. --version VER overrides derivation. Official-shaped VER (X.Y.Z, optional leading v) is accepted only when HEAD exact-matches that tag. Test-shaped VER is used as-is. Branch name: git symbolic-ref, or BUILD_BRANCH / CI_COMMIT_BRANCH / GITHUB_REF_NAME when detached, else "detached". Sanitized to [a-z0-9-], max 32 characters. EOF } root=. mode=canonical override= while (($#)); do case "$1" in -C) root=$2; shift 2 ;; --docker) mode=docker; shift ;; --version) override=$2; shift 2 ;; -h|--help) usage; exit 0 ;; *) echo "Error: unknown argument: $1" >&2; usage >&2; exit 2 ;; esac done if ! git -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "Error: not a git repository: $root" >&2 exit 2 fi root=$(git -C "$root" rev-parse --show-toplevel) gitc() { git -C "$root" "$@" } is_stable_tag() { [[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] } is_official_version() { [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] } # Mechanical sort key: next patch of a stable tag / X.Y.Z version. # v1.2.0 → 1.2.1. Not a claim that the next official release is 1.2.1. bump_patch() { local v=$1 major minor patch v=${v#v} IFS=. read -r major minor patch <<<"$v" printf '%s.%s.%s' "$major" "$minor" "$((10#$patch + 1))" } # Strip one leading v if present. strip_v() { local v=$1 if [[ "$v" == v* ]]; then v=${v#v} fi printf '%s' "$v" } sanitize_branch() { local b=$1 b=$(printf '%s' "$b" | tr '[:upper:]' '[:lower:]') b=${b//\//-} b=${b//_/-} b=$(printf '%s' "$b" | tr -cd 'a-z0-9-') b=$(printf '%s' "$b" | tr -s '-') b=${b#-} b=${b%-} if ((${#b} > 32)); then b=${b:0:32} b=${b%-} fi if [[ -z "$b" ]]; then b=unknown fi printf '%s' "$b" } branch_slug() { local b b=$(gitc rev-parse --abbrev-ref HEAD) if [[ "$b" == HEAD ]]; then b=${BUILD_BRANCH:-${CI_COMMIT_BRANCH:-${GITHUB_REF_NAME:-detached}}} fi sanitize_branch "$b" } short_sha() { gitc rev-parse HEAD | cut -c1-7 } # Build-day YYYYMMDD. SOURCE_DATE_EPOCH (UTC) wins when set, for reproducible # test packages; otherwise the machine's current local date. build_date() { if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]; then date -u -d "@${SOURCE_DATE_EPOCH}" +%Y%m%d else date +%Y%m%d fi } # Highest stable tag pointing at HEAD, or empty. official_tag_at_head() { local tag best= while IFS= read -r tag; do [[ -n "$tag" ]] || continue is_stable_tag "$tag" || continue if [[ -z "$best" ]]; then best=$tag elif printf '%s\n%s\n' "$best" "$tag" | sort -V | tail -n 1 | grep -qx "$tag"; then best=$tag fi done < <(gitc tag --points-at HEAD) printf '%s' "$best" } # Stable ancestor tag with the fewest commits to HEAD (not sort -V globally). nearest_stable_tag() { local tag dist best_dist="" best_tag="" while IFS= read -r tag; do [[ -n "$tag" ]] || continue is_stable_tag "$tag" || continue dist=$(gitc rev-list --count "${tag}..HEAD") if [[ -z "$best_dist" ]] || ((dist < best_dist)); then best_dist=$dist best_tag=$tag elif ((dist == best_dist)); then if printf '%s\n%s\n' "$best_tag" "$tag" | sort -V | tail -n 1 | grep -qx "$tag"; then best_tag=$tag fi fi done < <(gitc tag --merged HEAD) printf '%s' "$best_tag" } derive_canonical() { local tag base n sha branch ymd tag=$(official_tag_at_head) if [[ -n "$tag" ]]; then printf '%s' "${tag#v}" return fi sha=$(short_sha) branch=$(branch_slug) ymd=$(build_date) base=$(nearest_stable_tag) if [[ -z "$base" ]]; then printf '0.0.0~%s.%s+g%s' "$ymd" "$branch" "$sha" return fi n=$(gitc rev-list --count "${base}..HEAD") printf '%s~%s.%s.%s+g%s' "$(bump_patch "$base")" "$ymd" "$branch" "$n" "$sha" } to_docker() { local v=$1 v=${v//\~/-} v=${v//+g/.g} printf '%s' "$v" } canonical= if [[ -n "$override" ]]; then canonical=$(strip_v "$override") if [[ "$canonical" == *'_'* ]]; then echo "Error: version must not contain '_': $canonical" >&2 exit 2 fi if is_official_version "$canonical"; then derived=$(derive_canonical) if [[ "$derived" != "$canonical" ]]; then echo "Error: --version $canonical looks official but HEAD is $derived" >&2 echo "Official X.Y.Z is allowed only when HEAD exact-matches vX.Y.Z." >&2 exit 2 fi fi else canonical=$(derive_canonical) fi if [[ -z "$canonical" ]]; then echo "Error: empty version" >&2 exit 1 fi if [[ "$mode" == docker ]]; then tag=$(to_docker "$canonical") if [[ "$tag" == *:* || "$tag" == */* || "$tag" == *'~'* ]]; then echo "Error: docker tag still contains illegal characters: $tag" >&2 exit 1 fi printf '%s\n' "$tag" else printf '%s\n' "$canonical" fi