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)
基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`)。有基线时测试号把
patch 机械 +1,再加构建时间 `YYYYMMDDHHMM`、清洗后的分支名、相对距离、短 SHA。
patch 机械 +1,再加构建时间 `YYYYMMDDTHHMM`、清洗后的分支名、相对距离、短 SHA。
这是排序键,不是在选定下一正式 SemVer。测试与正式进**同一个** apt /
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`,
构建时间 `202609181430`(带分钟,同一天重建才排得对):
构建时间 `20260918T1430`(带分钟,同一天重建才排得对):
```text
规范 / 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
规范 / DEB 1.4.3~20260918T1430.feat-login-v2.7+gabc1234
文件名 foo_1.4.3~20260918T1430.feat-login-v2.7+gabc1234_amd64.deb
Docker registry/ns/foo:1.4.3-20260918T1430.feat-login-v2.7.gabc1234
```
Docker tag 由规范版本映射:`~` → `-`,`+g` → `.g`(Docker 不允许 `~`)。
无参 `apt upgrade` 会从上一正式版升到下一 patch 的测试包,但不会盖住同号
正式包。生产若不能自动吃到测试包,应拆仓库/组件。构建时间取本机当前本地
时间(含分钟);设了 `SOURCE_DATE_EPOCH` 则用该时刻的 UTC 时间。时间和日
期之间不写分隔符:Debian 把字母排在标点之前,`20260918T1430` 会小于同日旧
格式包的 `20260918.…`,同一天的包反而抢不到最新。`+g<sha>` 只用于记录
commit、不参与排序:只写到日期时,同一天重复构建的版本只差 `+g<sha>`,
Debian 版本比较把哈希当普通文本比,会把新包判成比旧包小。
时间(含分钟);设了 `SOURCE_DATE_EPOCH` 则用该时刻的 UTC 时间。`+g<sha>`
只用于记录 commit、不参与排序:只写到日期时,同一天重复构建的版本只差
`+g<sha>`,Debian 版本比较把哈希当普通文本比,会把新包判成比旧包小。
注意切换格式当天:Debian 把字母排在标点之前,`20260918T1430…` 小于旧格式
的 `20260918.…`,同一天已上传的旧格式包仍会压住新包,需要在仓库里把那些
旧包移除或停用;从第二天起新格式始终更大。
### 分支名清洗
+7 -7
View File
@@ -102,13 +102,13 @@ bugfix 测试包;同号正式版仍盖住测试。不要写成 `1.4.2~…`(
`1.4.3`)。日期必须写在 `~` 之后。Docker tag 不得含 `~`、`/`、`:`,由规范
版本把 `~` → `-`、`+g` → `.g`。
`<date>`:`YYYYMMDDHHMM`,构建的日期加分钟,中间不加分隔符。加分钟是
为了同一天重复构建也能排序:只写到日期时,同分支同日的版本前缀完全相
同,只剩 `+g<sha>` 可区分,而 Debian 把哈希当普通文本比较,新构建可能
排到旧构建下面(`apt upgrade` 因此看不到最新包)。不能写成
`YYYYMMDDTHHMM`:Debian 版本比较里字母排在标点之前,带 `T` 的版本会小于
同日旧格式包的 `YYYYMMDD.…`。已设 `SOURCE_DATE_EPOCH` 时用该时刻的 UTC
时间,否则用本机当前时间。正式 `X.Y.Z` 不加日期。
`<date>`:`YYYYMMDDTHHMM`,构建的日期加分钟。加分钟是为了同一天重复构建
也能排序:只写到日期时,同分支同日的版本前缀完全相同,只剩 `+g<sha>` 可
区分,而 Debian 把哈希当普通文本比较,新构建可能排到旧构建下面(
`apt upgrade` 因此看不到最新包)。已设 `SOURCE_DATE_EPOCH` 时用该时刻的
UTC 时间,否则用本机当前时间。正式 `X.Y.Z` 不加日期。切换格式当天注意:
Debian 把字母排在标点之前,`YYYYMMDDT…` 小于旧格式的 `YYYYMMDD.…`,同日
已上传的旧格式包会压住新包,需要把它们在仓库里移除或停用;次日恢复正常。
`<branch>`:`git rev-parse --abbrev-ref HEAD`,detached 时用
`BUILD_BRANCH` / `CI_COMMIT_BRANCH` / `GITHUB_REF_NAME`,再没有则
`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 «版本号»):
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
no tag no stable tag reachable from HEAD → 0.0.0~YYYYMMDDHHMM.branch+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~YYYYMMDDTHHMM.branch+gSHA
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.
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.
@@ -120,17 +121,15 @@ short_sha() {
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.
# 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.
# 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%d%H%M
date -u -d "@${SOURCE_DATE_EPOCH}" +%Y%m%dT%H%M
else
date +%Y%m%d%H%M
date +%Y%m%dT%H%M
fi
}
+22 -11
View File
@@ -17,7 +17,7 @@ VERSION_MK = SCRIPTS / "version.mk"
# Frozen build instant so test versions are deterministic.
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:
@@ -183,9 +183,11 @@ class BuilderVersionTests(unittest.TestCase):
"""同一天重建必须比上一包大,否则 apt 永远拿不到最新构建。
只写到日期时同分支同日的版本前缀完全相同,只剩 +g<sha> 可区分,
Debian 版本比较把哈希当普通文本,新包可能被判成比旧包小。带分钟的
时间必须与日期拼在同一段数字里:字母排在标点之前,YYYYMMDDTHHMM
这种带字母分隔的写法会小于同日旧格式包的 YYYYMMDD.…。
Debian 版本比较把哈希当普通文本,新包可能被判成比旧包小。
已知边界(故意断言):切换格式当天,旧格式同日包 `YYYYMMDD.…` 仍
大于新格式 `YYYYMMDDT…`,因为 Debian 把字母排在标点之前。那天需要
把旧格式包在仓库里移除或停用,或者等次日(次日新格式一定更大)。
"""
if shutil.which("dpkg") is None:
self.skipTest("dpkg not available")
@@ -200,17 +202,15 @@ class BuilderVersionTests(unittest.TestCase):
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)}",
"时间必须与日期拼成一段数字,不能插入字母分隔符",
f"0.0.0~20240101T1000.main+g{sha7(repo)}",
"构建时间精确到分钟,形如 YYYYMMDDTHHMM",
)
for label, a, b in (
("同日重建", second, first),
("已上传的旧格式同日包", second, "0.0.0~20240101.main+gaaaaaaa"),
("次日新格式包", "0.0.0~202401021000.main+gccccccc", second),
("旧格式前一日包", second, "0.0.0~20231231.main+gaaaaaaa"),
("次日新格式包", "0.0.0~20240102T1000.main+gccccccc", second),
("下一正式版", "0.1.0", second),
):
compared = subprocess.run(
@@ -220,6 +220,17 @@ class BuilderVersionTests(unittest.TestCase):
check=False,
)
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:
with tempfile.TemporaryDirectory() as temp:
@@ -250,7 +261,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~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:
with tempfile.TemporaryDirectory() as temp: