diff --git a/skills/builder/README.md b/skills/builder/README.md index 4195610..4dce39f 100644 --- a/skills/builder/README.md +++ b/skills/builder/README.md @@ -134,25 +134,29 @@ Docker registry/ns/foo:1.4.2 ### 测试(其它任何 commit) 基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`)。有基线时测试号把 -patch 机械 +1,再加构建日 `YYYYMMDD`、清洗后的分支名、相对距离、短 SHA。 +patch 机械 +1,再加构建时间 `YYYYMMDDHHMM`、清洗后的分支名、相对距离、短 SHA。 这是排序键,不是在选定下一正式 SemVer。测试与正式进**同一个** apt / Docker 仓库,所以 DEB 必须用 `~` 挂在下一 patch 上:`1.4.2` < -`1.4.3~20260908…` < `1.4.3`。已装上一正式版的机器可以升到 bugfix 测试包; +`1.4.3~202609181430…` < `1.4.3`。已装上一正式版的机器可以升到 bugfix 测试包; 同号正式版仍盖住测试。 分支 `feat/login-v2`,相对 `v1.4.2` 第 7 个 commit,SHA `abc1234`, -构建日 `20260908`: +构建时间 `202609181430`(带分钟,同一天重建才排得对): ```text -规范 / DEB 1.4.3~20260908.feat-login-v2.7+gabc1234 -文件名 foo_1.4.3~20260908.feat-login-v2.7+gabc1234_amd64.deb -Docker registry/ns/foo:1.4.3-20260908.feat-login-v2.7.gabc1234 +规范 / DEB 1.4.3~202609181430.feat-login-v2.7+gabc1234 +文件名 foo_1.4.3~202609181430.feat-login-v2.7+gabc1234_amd64.deb +Docker registry/ns/foo:1.4.3-202609181430.feat-login-v2.7.gabc1234 ``` Docker tag 由规范版本映射:`~` → `-`,`+g` → `.g`(Docker 不允许 `~`)。 无参 `apt upgrade` 会从上一正式版升到下一 patch 的测试包,但不会盖住同号 -正式包。生产若不能自动吃到测试包,应拆仓库/组件。构建日取本机当前日期; -设了 `SOURCE_DATE_EPOCH` 则用该时刻的 UTC 日期。 +正式包。生产若不能自动吃到测试包,应拆仓库/组件。构建时间取本机当前本地 +时间(含分钟);设了 `SOURCE_DATE_EPOCH` 则用该时刻的 UTC 时间。时间和日 +期之间不写分隔符:Debian 把字母排在标点之前,`20260918T1430` 会小于同日旧 +格式包的 `20260918.…`,同一天的包反而抢不到最新。`+g` 只用于记录 +commit、不参与排序:只写到日期时,同一天重复构建的版本只差 `+g`, +Debian 版本比较把哈希当普通文本比,会把新包判成比旧包小。 ### 分支名清洗 diff --git a/skills/builder/references/contract.md b/skills/builder/references/contract.md index 8f7cac0..8a286e3 100644 --- a/skills/builder/references/contract.md +++ b/skills/builder/references/contract.md @@ -102,8 +102,13 @@ bugfix 测试包;同号正式版仍盖住测试。不要写成 `1.4.2~…`( `1.4.3`)。日期必须写在 `~` 之后。Docker tag 不得含 `~`、`/`、`:`,由规范 版本把 `~` → `-`、`+g` → `.g`。 -``:构建日 `YYYYMMDD`。已设 `SOURCE_DATE_EPOCH` 时用该时刻的 UTC -日期,否则用本机当前日期。正式 `X.Y.Z` 不加日期。 +``:`YYYYMMDDHHMM`,构建的日期加分钟,中间不加分隔符。加分钟是 +为了同一天重复构建也能排序:只写到日期时,同分支同日的版本前缀完全相 +同,只剩 `+g` 可区分,而 Debian 把哈希当普通文本比较,新构建可能 +排到旧构建下面(`apt upgrade` 因此看不到最新包)。不能写成 +`YYYYMMDDTHHMM`:Debian 版本比较里字母排在标点之前,带 `T` 的版本会小于 +同日旧格式包的 `YYYYMMDD.…`。已设 `SOURCE_DATE_EPOCH` 时用该时刻的 UTC +时间,否则用本机当前时间。正式 `X.Y.Z` 不加日期。 ``:`git rev-parse --abbrev-ref HEAD`,detached 时用 `BUILD_BRANCH` / `CI_COMMIT_BRANCH` / `GITHUB_REF_NAME`,再没有则 `detached`。清洗:小写;`/`、`_` → `-`;去掉非 `[a-z0-9-]`;压缩连续 diff --git a/skills/builder/scripts/version.sh b/skills/builder/scripts/version.sh index 152ed6c..8b5546d 100755 --- a/skills/builder/scripts/version.sh +++ b/skills/builder/scripts/version.sh @@ -11,11 +11,19 @@ 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 + test nearest ancestor stable tag vX.Y.Z → X.Y.(Z+1)~YYYYMMDDHHMM.branch.n+gSHA + no tag no stable tag reachable from HEAD → 0.0.0~YYYYMMDDHHMM.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. +YYYYMMDDHHMM is the build minute (day plus time of day), with no separator: +the digits must stay in one run so Debian compares them numerically. A letter +separator (YYYYMMDDTHHMM) would compare *below* the bare date, because Debian +orders letters before punctuation — same-day packages built with the old +date-only scheme would outrank the new ones. 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). With the minute +in the version, later builds always compare greater. Stable tags match v.. only. Baseline is ancestry, not the highest version in the repository. This script does not fetch tags. @@ -112,13 +120,17 @@ 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() { +# Build-minute YYYYMMDDHHMM. 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~202609181036… < 0.0.0~202609181722…). It is +# glued to the date with no 'T' so the whole stamp stays one numeric run; +# '20260918T1722' would sort below '20260918.dev-…' and lose to old packages. +build_stamp() { if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]; then - date -u -d "@${SOURCE_DATE_EPOCH}" +%Y%m%d + date -u -d "@${SOURCE_DATE_EPOCH}" +%Y%m%d%H%M else - date +%Y%m%d + date +%Y%m%d%H%M fi } @@ -157,7 +169,7 @@ nearest_stable_tag() { } derive_canonical() { - local tag base n sha branch ymd + local tag base n sha branch stamp tag=$(official_tag_at_head) if [[ -n "$tag" ]]; then printf '%s' "${tag#v}" @@ -165,14 +177,14 @@ derive_canonical() { fi sha=$(short_sha) branch=$(branch_slug) - ymd=$(build_date) + stamp=$(build_stamp) base=$(nearest_stable_tag) if [[ -z "$base" ]]; then - printf '0.0.0~%s.%s+g%s' "$ymd" "$branch" "$sha" + 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")" "$ymd" "$branch" "$n" "$sha" + printf '%s~%s.%s.%s+g%s' "$(bump_patch "$base")" "$stamp" "$branch" "$n" "$sha" } to_docker() { diff --git a/tests/test_builder_version.py b/tests/test_builder_version.py index 4543635..9780ea4 100644 --- a/tests/test_builder_version.py +++ b/tests/test_builder_version.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import shutil import stat import subprocess import tempfile @@ -14,9 +15,9 @@ VERSION_SH = SCRIPTS / "version.sh" PUBLISH_DOCKER = SCRIPTS / "publish_docker.sh" VERSION_MK = SCRIPTS / "version.mk" -# Frozen build date so test versions are deterministic. +# Frozen build instant so test versions are deterministic. SOURCE_DATE_EPOCH = "1704067200" # 2024-01-01T00:00:00Z -BUILD_DATE = "20240101" +BUILD_STAMP = "202401010000" def git(cwd: Path, *args: str, env: dict[str, str] | None = None) -> str: @@ -106,12 +107,12 @@ class BuilderVersionTests(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual( result.stdout.strip(), - f"1.2.1~{BUILD_DATE}.main.1+g{sha}", + f"1.2.1~{BUILD_STAMP}.main.1+g{sha}", ) docker = version_sh("--docker", cwd=repo) self.assertEqual( docker.stdout.strip(), - f"1.2.1-{BUILD_DATE}.main.1.g{sha}", + f"1.2.1-{BUILD_STAMP}.main.1.g{sha}", ) def test_test_version_uses_ancestor_not_global_max(self) -> None: @@ -141,12 +142,12 @@ class BuilderVersionTests(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual( result.stdout.strip(), - f"1.4.3~{BUILD_DATE}.feat-login-v2.2+g{feature_sha}", + f"1.4.3~{BUILD_STAMP}.feat-login-v2.2+g{feature_sha}", ) docker = version_sh("--docker", cwd=repo) self.assertEqual( docker.stdout.strip(), - f"1.4.3-{BUILD_DATE}.feat-login-v2.2.g{feature_sha}", + f"1.4.3-{BUILD_STAMP}.feat-login-v2.2.g{feature_sha}", ) def test_ignores_prerelease_and_app_tags(self) -> None: @@ -164,7 +165,7 @@ class BuilderVersionTests(unittest.TestCase): sha = sha7(repo) result = version_sh(cwd=repo) self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.strip(), f"1.0.1~{BUILD_DATE}.main.1+g{sha}") + self.assertEqual(result.stdout.strip(), f"1.0.1~{BUILD_STAMP}.main.1+g{sha}") def test_no_stable_tag(self) -> None: with tempfile.TemporaryDirectory() as temp: @@ -176,7 +177,49 @@ class BuilderVersionTests(unittest.TestCase): sha = sha7(repo) result = version_sh(cwd=repo) self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.strip(), f"0.0.0~{BUILD_DATE}.main+g{sha}") + self.assertEqual(result.stdout.strip(), f"0.0.0~{BUILD_STAMP}.main+g{sha}") + + def test_same_day_rebuild_sorts_higher(self) -> None: + """同一天重建必须比上一包大,否则 apt 永远拿不到最新构建。 + + 只写到日期时同分支同日的版本前缀完全相同,只剩 +g 可区分, + Debian 版本比较把哈希当普通文本,新包可能被判成比旧包小。带分钟的 + 时间必须与日期拼在同一段数字里:字母排在标点之前,YYYYMMDDTHHMM + 这种带字母分隔的写法会小于同日旧格式包的 YYYYMMDD.…。 + """ + if shutil.which("dpkg") is None: + self.skipTest("dpkg not available") + with tempfile.TemporaryDirectory() as temp: + repo = Path(temp) / "repo" + repo.mkdir() + init_repo(repo) + (repo / "a").write_text("1\n", encoding="utf-8") + commit(repo, "init") + early = version_sh(cwd=repo, env={"SOURCE_DATE_EPOCH": "1704067200"}) # 00:00Z + later = version_sh(cwd=repo, env={"SOURCE_DATE_EPOCH": "1704103200"}) # 10:00Z + self.assertEqual(early.returncode, 0, early.stderr) + self.assertEqual(later.returncode, 0, later.stderr) + first, second = early.stdout.strip(), later.stdout.strip() + self.assertEqual(first.rsplit(".", 1)[0].split("~")[1].split(".")[0], "202401010000") + self.assertEqual(second.rsplit(".", 1)[0].split("~")[1].split(".")[0], "202401011000") + self.assertEqual( + second, + f"0.0.0~202401011000.main+g{sha7(repo)}", + "时间必须与日期拼成一段数字,不能插入字母分隔符", + ) + for label, a, b in ( + ("同日重建", second, first), + ("已上传的旧格式同日包", second, "0.0.0~20240101.main+gaaaaaaa"), + ("次日新格式包", "0.0.0~202401021000.main+gccccccc", second), + ("下一正式版", "0.1.0", second), + ): + compared = subprocess.run( + ["dpkg", "--compare-versions", a, "gt", b], + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(compared.returncode, 0, f"{label}:{a} 应大于 {b}") def test_detached_uses_env_branch(self) -> None: with tempfile.TemporaryDirectory() as temp: @@ -192,7 +235,7 @@ class BuilderVersionTests(unittest.TestCase): git(repo, "checkout", "--detach", "HEAD") result = version_sh(cwd=repo, env={"BUILD_BRANCH": "ci_job"}) self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.strip(), f"0.1.1~{BUILD_DATE}.ci-job.1+g{sha}") + self.assertEqual(result.stdout.strip(), f"0.1.1~{BUILD_STAMP}.ci-job.1+g{sha}") def test_source_date_epoch_changes_test_date(self) -> None: with tempfile.TemporaryDirectory() as temp: @@ -207,7 +250,7 @@ class BuilderVersionTests(unittest.TestCase): sha = sha7(repo) later = version_sh(cwd=repo, env={"SOURCE_DATE_EPOCH": "1725148800"}) self.assertEqual(later.returncode, 0, later.stderr) - self.assertEqual(later.stdout.strip(), f"1.0.1~20240901.main.1+g{sha}") + self.assertEqual(later.stdout.strip(), f"1.0.1~202409010000.main.1+g{sha}") def test_official_version_ignores_build_date(self) -> None: with tempfile.TemporaryDirectory() as temp: