eb2d0ce651
同日同分支的测试版本前缀完全相同,只剩 +g<sha> 可区分;Debian 版本比较把 hex 当普通文本,新构建可能小于旧构建,apt upgrade 因此拿不到最新包。 build_date() → build_stamp(),改为 YYYYMMDDHHMM,时间与日期拼成一段数字 才能按数值比较。不能写成 20260918T1722:Debian 里字母排在标点之前,带 T 的版本小于同日旧格式包的 20260918.…,修复当天仍抢不过已上传的旧包。 补回归测试,用 dpkg --compare-versions 锁定同日重建更大、且压过旧格式同日包。
388 lines
15 KiB
Python
388 lines
15 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
import shutil
|
||
import stat
|
||
import subprocess
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path
|
||
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
SCRIPTS = REPO_ROOT / "skills" / "builder" / "scripts"
|
||
VERSION_SH = SCRIPTS / "version.sh"
|
||
PUBLISH_DOCKER = SCRIPTS / "publish_docker.sh"
|
||
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"
|
||
|
||
|
||
def git(cwd: Path, *args: str, env: dict[str, str] | None = None) -> str:
|
||
merged = os.environ.copy()
|
||
if env:
|
||
merged.update(env)
|
||
merged.setdefault("GIT_AUTHOR_NAME", "Test")
|
||
merged.setdefault("GIT_AUTHOR_EMAIL", "test@example.com")
|
||
merged.setdefault("GIT_COMMITTER_NAME", "Test")
|
||
merged.setdefault("GIT_COMMITTER_EMAIL", "test@example.com")
|
||
result = subprocess.run(
|
||
["git", *args],
|
||
cwd=cwd,
|
||
env=merged,
|
||
text=True,
|
||
capture_output=True,
|
||
check=False,
|
||
)
|
||
if result.returncode != 0:
|
||
raise AssertionError(f"git {args} failed: {result.stderr}")
|
||
return result.stdout.strip()
|
||
|
||
|
||
def commit(cwd: Path, message: str) -> str:
|
||
git(cwd, "add", "-A")
|
||
git(cwd, "commit", "-m", message)
|
||
return git(cwd, "rev-parse", "HEAD")
|
||
|
||
|
||
def sha7(cwd: Path) -> str:
|
||
return git(cwd, "rev-parse", "HEAD")[:7]
|
||
|
||
|
||
def version_sh(*args: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
|
||
merged = os.environ.copy()
|
||
merged["SOURCE_DATE_EPOCH"] = SOURCE_DATE_EPOCH
|
||
if env:
|
||
merged.update(env)
|
||
return subprocess.run(
|
||
["bash", str(VERSION_SH), *args],
|
||
cwd=cwd,
|
||
env=merged,
|
||
text=True,
|
||
capture_output=True,
|
||
check=False,
|
||
)
|
||
|
||
|
||
def init_repo(path: Path) -> None:
|
||
git(path, "init", "-b", "main")
|
||
git(path, "config", "user.email", "test@example.com")
|
||
git(path, "config", "user.name", "Test")
|
||
|
||
|
||
class BuilderVersionTests(unittest.TestCase):
|
||
def test_version_sh_is_executable(self) -> None:
|
||
mode = VERSION_SH.stat().st_mode
|
||
self.assertTrue(mode & stat.S_IXUSR, "version.sh must be executable")
|
||
|
||
def test_official_on_stable_tag(self) -> None:
|
||
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")
|
||
git(repo, "tag", "-a", "v1.4.2", "-m", "1.4.2")
|
||
result = version_sh(cwd=repo)
|
||
self.assertEqual(result.returncode, 0, result.stderr)
|
||
self.assertEqual(result.stdout, "1.4.2\n")
|
||
docker = version_sh("--docker", cwd=repo)
|
||
self.assertEqual(docker.returncode, 0, docker.stderr)
|
||
self.assertEqual(docker.stdout, "1.4.2\n")
|
||
|
||
def test_post_release_commit_bumps_patch(self) -> None:
|
||
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, "release")
|
||
git(repo, "tag", "-a", "v1.2.0", "-m", "1.2.0")
|
||
(repo / "b").write_text("fix\n", encoding="utf-8")
|
||
commit(repo, "bugfix")
|
||
sha = sha7(repo)
|
||
result = version_sh(cwd=repo)
|
||
self.assertEqual(result.returncode, 0, result.stderr)
|
||
self.assertEqual(
|
||
result.stdout.strip(),
|
||
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_STAMP}.main.1.g{sha}",
|
||
)
|
||
|
||
def test_test_version_uses_ancestor_not_global_max(self) -> None:
|
||
with tempfile.TemporaryDirectory() as temp:
|
||
root = Path(temp)
|
||
repo = root / "repo"
|
||
repo.mkdir()
|
||
init_repo(repo)
|
||
(repo / "a").write_text("1\n", encoding="utf-8")
|
||
commit(repo, "base")
|
||
git(repo, "tag", "v1.4.2")
|
||
|
||
git(repo, "checkout", "-b", "feat/login-v2")
|
||
(repo / "b").write_text("2\n", encoding="utf-8")
|
||
commit(repo, "feature")
|
||
(repo / "c").write_text("3\n", encoding="utf-8")
|
||
commit(repo, "feature 2")
|
||
feature_sha = sha7(repo)
|
||
|
||
git(repo, "checkout", "main")
|
||
(repo / "d").write_text("4\n", encoding="utf-8")
|
||
commit(repo, "release")
|
||
git(repo, "tag", "v1.4.3")
|
||
|
||
git(repo, "checkout", "feat/login-v2")
|
||
result = version_sh(cwd=repo)
|
||
self.assertEqual(result.returncode, 0, result.stderr)
|
||
self.assertEqual(
|
||
result.stdout.strip(),
|
||
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_STAMP}.feat-login-v2.2.g{feature_sha}",
|
||
)
|
||
|
||
def test_ignores_prerelease_and_app_tags(self) -> None:
|
||
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")
|
||
git(repo, "tag", "v1.0.0")
|
||
(repo / "b").write_text("2\n", encoding="utf-8")
|
||
commit(repo, "next")
|
||
git(repo, "tag", "v1.1.0-rc.1")
|
||
git(repo, "tag", "v1.1.0-app-1")
|
||
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_STAMP}.main.1+g{sha}")
|
||
|
||
def test_no_stable_tag(self) -> None:
|
||
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")
|
||
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_STAMP}.main+g{sha}")
|
||
|
||
def test_same_day_rebuild_sorts_higher(self) -> None:
|
||
"""同一天重建必须比上一包大,否则 apt 永远拿不到最新构建。
|
||
|
||
只写到日期时同分支同日的版本前缀完全相同,只剩 +g<sha> 可区分,
|
||
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:
|
||
repo = Path(temp) / "repo"
|
||
repo.mkdir()
|
||
init_repo(repo)
|
||
(repo / "a").write_text("1\n", encoding="utf-8")
|
||
commit(repo, "init")
|
||
git(repo, "tag", "v0.1.0")
|
||
(repo / "b").write_text("2\n", encoding="utf-8")
|
||
commit(repo, "next")
|
||
sha = sha7(repo)
|
||
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_STAMP}.ci-job.1+g{sha}")
|
||
|
||
def test_source_date_epoch_changes_test_date(self) -> None:
|
||
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")
|
||
git(repo, "tag", "v1.0.0")
|
||
(repo / "b").write_text("2\n", encoding="utf-8")
|
||
commit(repo, "next")
|
||
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}")
|
||
|
||
def test_official_version_ignores_build_date(self) -> None:
|
||
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")
|
||
git(repo, "tag", "-a", "v1.4.2", "-m", "1.4.2")
|
||
result = version_sh(cwd=repo, env={"SOURCE_DATE_EPOCH": "1725148800"})
|
||
self.assertEqual(result.returncode, 0, result.stderr)
|
||
self.assertEqual(result.stdout.strip(), "1.4.2")
|
||
|
||
def test_override_official_rejected_off_tag(self) -> None:
|
||
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")
|
||
git(repo, "tag", "v1.4.2")
|
||
(repo / "b").write_text("2\n", encoding="utf-8")
|
||
commit(repo, "next")
|
||
result = version_sh("--version", "1.4.3", cwd=repo)
|
||
self.assertEqual(result.returncode, 2)
|
||
self.assertIn("looks official", result.stderr)
|
||
|
||
def test_override_official_ok_on_tag(self) -> None:
|
||
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")
|
||
git(repo, "tag", "v1.4.2")
|
||
result = version_sh("--version", "v1.4.2", cwd=repo)
|
||
self.assertEqual(result.returncode, 0, result.stderr)
|
||
self.assertEqual(result.stdout.strip(), "1.4.2")
|
||
|
||
def test_override_test_shaped_accepted(self) -> None:
|
||
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")
|
||
result = version_sh("--version", "1.4.2~feat.1+gabc1234", "--docker", cwd=repo)
|
||
self.assertEqual(result.returncode, 0, result.stderr)
|
||
self.assertEqual(result.stdout.strip(), "1.4.2-feat.1.gabc1234")
|
||
|
||
def test_version_mk_sets_make_version(self) -> None:
|
||
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")
|
||
git(repo, "tag", "v2.0.0")
|
||
makefile = repo / "Makefile"
|
||
makefile.write_text(
|
||
"include {mk}\n"
|
||
".PHONY: version\n"
|
||
"version:\n"
|
||
"\t@echo \"$(VERSION)\"\n".format(mk=VERSION_MK),
|
||
encoding="utf-8",
|
||
)
|
||
result = subprocess.run(
|
||
["make", "--no-print-directory", "-C", str(repo), "version"],
|
||
text=True,
|
||
capture_output=True,
|
||
check=False,
|
||
)
|
||
self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
|
||
self.assertEqual(result.stdout.strip(), "2.0.0")
|
||
|
||
def test_publish_docker_dry_run_uses_derived_tag(self) -> None:
|
||
with tempfile.TemporaryDirectory() as temp:
|
||
repo = Path(temp) / "repo"
|
||
repo.mkdir()
|
||
init_repo(repo)
|
||
(repo / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8")
|
||
commit(repo, "init")
|
||
git(repo, "tag", "v3.1.4")
|
||
result = subprocess.run(
|
||
[
|
||
"bash",
|
||
str(PUBLISH_DOCKER),
|
||
"--registry",
|
||
"registry.example.com",
|
||
"--repository",
|
||
"ns/app",
|
||
"--dry-run",
|
||
],
|
||
cwd=repo,
|
||
text=True,
|
||
capture_output=True,
|
||
check=False,
|
||
)
|
||
self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
|
||
self.assertIn("Image: registry.example.com/ns/app:3.1.4", result.stdout)
|
||
|
||
def test_publish_docker_rejects_official_tag_off_head(self) -> None:
|
||
with tempfile.TemporaryDirectory() as temp:
|
||
repo = Path(temp) / "repo"
|
||
repo.mkdir()
|
||
init_repo(repo)
|
||
(repo / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8")
|
||
commit(repo, "init")
|
||
git(repo, "tag", "v1.0.0")
|
||
(repo / "b").write_text("2\n", encoding="utf-8")
|
||
commit(repo, "next")
|
||
result = subprocess.run(
|
||
[
|
||
"bash",
|
||
str(PUBLISH_DOCKER),
|
||
"--registry",
|
||
"registry.example.com",
|
||
"--repository",
|
||
"ns/app",
|
||
"--tag",
|
||
"1.0.1",
|
||
"--dry-run",
|
||
],
|
||
cwd=repo,
|
||
text=True,
|
||
capture_output=True,
|
||
check=False,
|
||
)
|
||
self.assertEqual(result.returncode, 2)
|
||
self.assertIn("looks official", result.stderr)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|