e60755f280
按要求保留字母分隔符:20260918T1722。分钟精度不变,同一分支同日多次 构建仍能正确排序。 记录切换边界:Debian 里字母排在标点之前,YYYYMMDDT… 小于旧格式的 YYYYMMDD.…,切换当天同日的旧格式包会压住新包,需要在仓库里把那些旧包 移除或停用;次日新格式始终更大。回归测试显式断言这行边界。
230 lines
6.2 KiB
Bash
Executable File
230 lines
6.2 KiB
Bash
Executable File
#!/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<major>.<minor>.<patch> 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
|