fix(builder): 版本时间格式改回 YYYYMMDDTHHMM

按要求保留字母分隔符:20260918T1722。分钟精度不变,同一分支同日多次
构建仍能正确排序。

记录切换边界:Debian 里字母排在标点之前,YYYYMMDDT… 小于旧格式的
YYYYMMDD.…,切换当天同日的旧格式包会压住新包,需要在仓库里把那些旧包
移除或停用;次日新格式始终更大。回归测试显式断言这行边界。
This commit is contained in:
2026-09-19 13:32:16 +08:00
parent eb2d0ce651
commit e60755f280
4 changed files with 58 additions and 47 deletions
+12 -11
View File
@@ -134,29 +134,30 @@ Docker registry/ns/foo:1.4.2
### 测试(其它任何 commit) ### 测试(其它任何 commit)
基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`)。有基线时测试号把 基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`)。有基线时测试号把
patch 机械 +1,再加构建时间 `YYYYMMDDHHMM`、清洗后的分支名、相对距离、短 SHA。 patch 机械 +1,再加构建时间 `YYYYMMDDTHHMM`、清洗后的分支名、相对距离、短 SHA。
这是排序键,不是在选定下一正式 SemVer。测试与正式进**同一个** apt / 这是排序键,不是在选定下一正式 SemVer。测试与正式进**同一个** apt /
Docker 仓库,所以 DEB 必须用 `~` 挂在下一 patch 上:`1.4.2` < Docker 仓库,所以 DEB 必须用 `~` 挂在下一 patch 上:`1.4.2` <
`1.4.3~202609181430…` < `1.4.3`。已装上一正式版的机器可以升到 bugfix 测试包; `1.4.3~20260918T1430…` < `1.4.3`。已装上一正式版的机器可以升到 bugfix 测试包;
同号正式版仍盖住测试。 同号正式版仍盖住测试。
分支 `feat/login-v2`,相对 `v1.4.2` 第 7 个 commit,SHA `abc1234`, 分支 `feat/login-v2`,相对 `v1.4.2` 第 7 个 commit,SHA `abc1234`,
构建时间 `202609181430`(带分钟,同一天重建才排得对): 构建时间 `20260918T1430`(带分钟,同一天重建才排得对):
```text ```text
规范 / DEB 1.4.3~202609181430.feat-login-v2.7+gabc1234 规范 / DEB 1.4.3~20260918T1430.feat-login-v2.7+gabc1234
文件名 foo_1.4.3~202609181430.feat-login-v2.7+gabc1234_amd64.deb 文件名 foo_1.4.3~20260918T1430.feat-login-v2.7+gabc1234_amd64.deb
Docker registry/ns/foo:1.4.3-202609181430.feat-login-v2.7.gabc1234 Docker registry/ns/foo:1.4.3-20260918T1430.feat-login-v2.7.gabc1234
``` ```
Docker tag 由规范版本映射:`~` → `-`,`+g` → `.g`(Docker 不允许 `~`)。 Docker tag 由规范版本映射:`~` → `-`,`+g` → `.g`(Docker 不允许 `~`)。
无参 `apt upgrade` 会从上一正式版升到下一 patch 的测试包,但不会盖住同号 无参 `apt upgrade` 会从上一正式版升到下一 patch 的测试包,但不会盖住同号
正式包。生产若不能自动吃到测试包,应拆仓库/组件。构建时间取本机当前本地 正式包。生产若不能自动吃到测试包,应拆仓库/组件。构建时间取本机当前本地
时间(含分钟);设了 `SOURCE_DATE_EPOCH` 则用该时刻的 UTC 时间。时间和日 时间(含分钟);设了 `SOURCE_DATE_EPOCH` 则用该时刻的 UTC 时间。`+g<sha>`
期之间不写分隔符:Debian 把字母排在标点之前,`20260918T1430` 会小于同日旧 只用于记录 commit、不参与排序:只写到日期时,同一天重复构建的版本只差
格式包的 `20260918.…`,同一天的包反而抢不到最新。`+g<sha>` 只用于记录 `+g<sha>`,Debian 版本比较把哈希当普通文本比,会把新包判成比旧包小。
commit、不参与排序:只写到日期时,同一天重复构建的版本只差 `+g<sha>`, 注意切换格式当天:Debian 把字母排在标点之前,`20260918T1430…` 小于旧格式
Debian 版本比较把哈希当普通文本比,会把新包判成比旧包小。 的 `20260918.…`,同一天已上传的旧格式包仍会压住新包,需要在仓库里把那些
旧包移除或停用;从第二天起新格式始终更大。
### 分支名清洗 ### 分支名清洗
+7 -7
View File
@@ -102,13 +102,13 @@ bugfix 测试包;同号正式版仍盖住测试。不要写成 `1.4.2~…`(
`1.4.3`)。日期必须写在 `~` 之后。Docker tag 不得含 `~`、`/`、`:`,由规范 `1.4.3`)。日期必须写在 `~` 之后。Docker tag 不得含 `~`、`/`、`:`,由规范
版本把 `~` → `-`、`+g` → `.g`。 版本把 `~` → `-`、`+g` → `.g`。
`<date>`:`YYYYMMDDHHMM`,构建的日期加分钟,中间不加分隔符。加分钟是 `<date>`:`YYYYMMDDTHHMM`,构建的日期加分钟。加分钟是为了同一天重复构建
为了同一天重复构建也能排序:只写到日期时,同分支同日的版本前缀完全相 也能排序:只写到日期时,同分支同日的版本前缀完全相同,只剩 `+g<sha>` 可
同,只剩 `+g<sha>` 可区分,而 Debian 把哈希当普通文本比较,新构建可能 区分,而 Debian 把哈希当普通文本比较,新构建可能排到旧构建下面(
排到旧构建下面(`apt upgrade` 因此看不到最新包)。不能写成 `apt upgrade` 因此看不到最新包)。已设 `SOURCE_DATE_EPOCH` 时用该时刻的
`YYYYMMDDTHHMM`:Debian 版本比较里字母排在标点之前,带 `T` 的版本会小于 UTC 时间,否则用本机当前时间。正式 `X.Y.Z` 不加日期。切换格式当天注意:
同日旧格式包的 `YYYYMMDD.…`。已设 `SOURCE_DATE_EPOCH` 时用该时刻的 UTC Debian 把字母排在标点之前,`YYYYMMDDT…` 小于旧格式的 `YYYYMMDD.…`,同日
时间,否则用本机当前时间。正式 `X.Y.Z` 不加日期。 已上传的旧格式包会压住新包,需要把它们在仓库里移除或停用;次日恢复正常。
`<branch>`:`git rev-parse --abbrev-ref HEAD`,detached 时用 `<branch>`:`git rev-parse --abbrev-ref HEAD`,detached 时用
`BUILD_BRANCH` / `CI_COMMIT_BRANCH` / `GITHUB_REF_NAME`,再没有则 `BUILD_BRANCH` / `CI_COMMIT_BRANCH` / `GITHUB_REF_NAME`,再没有则
`detached`。清洗:小写;`/`、`_` → `-`;去掉非 `[a-z0-9-]`;压缩连续 `detached`。清洗:小写;`/`、`_` → `-`;去掉非 `[a-z0-9-]`;压缩连续
+17 -18
View File
@@ -11,19 +11,20 @@ With --docker, print the Docker tag rendering of that version.
Derivation (builder contract «版本号»): Derivation (builder contract «版本号»):
official HEAD exact-match of vX.Y.Z → X.Y.Z official HEAD exact-match of vX.Y.Z → X.Y.Z
test nearest ancestor stable tag vX.Y.Z → X.Y.(Z+1)~YYYYMMDDHHMM.branch.n+gSHA 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~YYYYMMDDHHMM.branch+gSHA no tag no stable tag reachable from HEAD → 0.0.0~YYYYMMDDTHHMM.branch+gSHA
YYYYMMDDHHMM is the build minute (day plus time of day), with no separator: YYYYMMDDTHHMM is the build minute (day plus time of day). SOURCE_DATE_EPOCH,
the digits must stay in one run so Debian compares them numerically. A letter if set, is that instant in UTC; otherwise the local date and time. Official
separator (YYYYMMDDTHHMM) would compare *below* the bare date, because Debian X.Y.Z never gets a date. Minute precision is required for apt: Debian version
orders letters before punctuation — same-day packages built with the old comparison ignores upload order, so same-day rebuilds that differ only in
date-only scheme would outrank the new ones. SOURCE_DATE_EPOCH, if set, is +gSHA compare as arbitrary text (a newer build can sort below an older one).
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 Switching from the date-only scheme: Debian orders letters below punctuation,
ignores upload order, so same-day rebuilds that differ only in +gSHA compare so 'YYYYMMDDT…' compares *below* the old 'YYYYMMDD.…' form. Packages built on
as arbitrary text (a newer build can sort below an older one). With the minute the switch day therefore rank under that day's date-only packages until those
in the version, later builds always compare greater. 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 Stable tags match v<major>.<minor>.<patch> only. Baseline is ancestry, not
the highest version in the repository. This script does not fetch tags. the highest version in the repository. This script does not fetch tags.
@@ -120,17 +121,15 @@ short_sha() {
gitc rev-parse HEAD | cut -c1-7 gitc rev-parse HEAD | cut -c1-7
} }
# Build-minute YYYYMMDDHHMM. SOURCE_DATE_EPOCH (UTC) wins when set, for # Build-minute YYYYMMDDTHHMM. SOURCE_DATE_EPOCH (UTC) wins when set, for
# reproducible test packages; otherwise the machine's current local time. # reproducible test packages; otherwise the machine's current local time.
# The minute is not decoration: it is what keeps same-day rebuilds ordered in # 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 # Debian version comparison (0.0.0~20260918T1036… < 0.0.0~20260918T1722…).
# 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() { build_stamp() {
if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]; then if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]; then
date -u -d "@${SOURCE_DATE_EPOCH}" +%Y%m%d%H%M date -u -d "@${SOURCE_DATE_EPOCH}" +%Y%m%dT%H%M
else else
date +%Y%m%d%H%M date +%Y%m%dT%H%M
fi fi
} }
+22 -11
View File
@@ -17,7 +17,7 @@ VERSION_MK = SCRIPTS / "version.mk"
# Frozen build instant so test versions are deterministic. # Frozen build instant so test versions are deterministic.
SOURCE_DATE_EPOCH = "1704067200" # 2024-01-01T00:00:00Z SOURCE_DATE_EPOCH = "1704067200" # 2024-01-01T00:00:00Z
BUILD_STAMP = "202401010000" BUILD_STAMP = "20240101T0000"
def git(cwd: Path, *args: str, env: dict[str, str] | None = None) -> str: def git(cwd: Path, *args: str, env: dict[str, str] | None = None) -> str:
@@ -183,9 +183,11 @@ class BuilderVersionTests(unittest.TestCase):
"""同一天重建必须比上一包大,否则 apt 永远拿不到最新构建。 """同一天重建必须比上一包大,否则 apt 永远拿不到最新构建。
只写到日期时同分支同日的版本前缀完全相同,只剩 +g<sha> 可区分, 只写到日期时同分支同日的版本前缀完全相同,只剩 +g<sha> 可区分,
Debian 版本比较把哈希当普通文本,新包可能被判成比旧包小。带分钟的 Debian 版本比较把哈希当普通文本,新包可能被判成比旧包小。
时间必须与日期拼在同一段数字里:字母排在标点之前,YYYYMMDDTHHMM
这种带字母分隔的写法会小于同日旧格式包的 YYYYMMDD.…。 已知边界(故意断言):切换格式当天,旧格式同日包 `YYYYMMDD.…` 仍
大于新格式 `YYYYMMDDT…`,因为 Debian 把字母排在标点之前。那天需要
把旧格式包在仓库里移除或停用,或者等次日(次日新格式一定更大)。
""" """
if shutil.which("dpkg") is None: if shutil.which("dpkg") is None:
self.skipTest("dpkg not available") self.skipTest("dpkg not available")
@@ -200,17 +202,15 @@ class BuilderVersionTests(unittest.TestCase):
self.assertEqual(early.returncode, 0, early.stderr) self.assertEqual(early.returncode, 0, early.stderr)
self.assertEqual(later.returncode, 0, later.stderr) self.assertEqual(later.returncode, 0, later.stderr)
first, second = early.stdout.strip(), later.stdout.strip() 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( self.assertEqual(
second, second,
f"0.0.0~202401011000.main+g{sha7(repo)}", f"0.0.0~20240101T1000.main+g{sha7(repo)}",
"时间必须与日期拼成一段数字,不能插入字母分隔符", "构建时间精确到分钟,形如 YYYYMMDDTHHMM",
) )
for label, a, b in ( for label, a, b in (
("同日重建", second, first), ("同日重建", second, first),
("已上传的旧格式同日包", second, "0.0.0~20240101.main+gaaaaaaa"), ("旧格式前一日包", second, "0.0.0~20231231.main+gaaaaaaa"),
("次日新格式包", "0.0.0~202401021000.main+gccccccc", second), ("次日新格式包", "0.0.0~20240102T1000.main+gccccccc", second),
("下一正式版", "0.1.0", second), ("下一正式版", "0.1.0", second),
): ):
compared = subprocess.run( compared = subprocess.run(
@@ -220,6 +220,17 @@ class BuilderVersionTests(unittest.TestCase):
check=False, check=False,
) )
self.assertEqual(compared.returncode, 0, f"{label}:{a} 应大于 {b}") self.assertEqual(compared.returncode, 0, f"{label}:{a} 应大于 {b}")
compared = subprocess.run(
["dpkg", "--compare-versions", "0.0.0~20240101.main+gaaaaaaa", "gt", second],
text=True,
capture_output=True,
check=False,
)
self.assertEqual(
compared.returncode,
0,
"已知边界:旧格式同日包大于 YYYYMMDDT…,切换当天需要移除旧包或等次日",
)
def test_detached_uses_env_branch(self) -> None: def test_detached_uses_env_branch(self) -> None:
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
@@ -250,7 +261,7 @@ class BuilderVersionTests(unittest.TestCase):
sha = sha7(repo) sha = sha7(repo)
later = version_sh(cwd=repo, env={"SOURCE_DATE_EPOCH": "1725148800"}) later = version_sh(cwd=repo, env={"SOURCE_DATE_EPOCH": "1725148800"})
self.assertEqual(later.returncode, 0, later.stderr) self.assertEqual(later.returncode, 0, later.stderr)
self.assertEqual(later.stdout.strip(), f"1.0.1~202409010000.main.1+g{sha}") self.assertEqual(later.stdout.strip(), f"1.0.1~20240901T0000.main.1+g{sha}")
def test_official_version_ignores_build_date(self) -> None: def test_official_version_ignores_build_date(self) -> None:
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp: