#!/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)~YYYYMMDDTHHMM.branch.n+gSHA no tag no stable tag reachable from HEAD → 0.0.0~YYYYMMDDTHHMM.branch+gSHA YYYYMMDDTHHMM is the build minute (day plus time of day). SOURCE_DATE_EPOCH, if set, is that instant in UTC; otherwise the local date and time. Official X.Y.Z never gets a date. Minute precision is required for apt: Debian version comparison ignores upload order, so same-day rebuilds that differ only in +gSHA compare as arbitrary text (a newer build can sort below an older one). Switching from the date-only scheme: Debian orders letters below punctuation, so 'YYYYMMDDT…' compares *below* the old 'YYYYMMDD.…' form. Packages built on the switch day therefore rank under that day's date-only packages until those are removed or disabled in the repository; from the next day on, the new form always ranks higher. 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-minute YYYYMMDDTHHMM. SOURCE_DATE_EPOCH (UTC) wins when set, for # reproducible test packages; otherwise the machine's current local time. # The minute is not decoration: it is what keeps same-day rebuilds ordered in # Debian version comparison (0.0.0~20260918T1036… < 0.0.0~20260918T1722…). build_stamp() { if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]; then date -u -d "@${SOURCE_DATE_EPOCH}" +%Y%m%dT%H%M else date +%Y%m%dT%H%M 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 stamp tag=$(official_tag_at_head) if [[ -n "$tag" ]]; then printf '%s' "${tag#v}" return fi sha=$(short_sha) branch=$(branch_slug) stamp=$(build_stamp) base=$(nearest_stable_tag) if [[ -z "$base" ]]; then printf '0.0.0~%s.%s+g%s' "$stamp" "$branch" "$sha" return fi n=$(gitc rev-list --count "${base}..HEAD") printf '%s~%s.%s.%s+g%s' "$(bump_patch "$base")" "$stamp" "$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