diff --git a/.gitea/scripts/ci_checkout.sh b/.gitea/scripts/ci_checkout.sh index 4dc5eef..37c4b52 100755 --- a/.gitea/scripts/ci_checkout.sh +++ b/.gitea/scripts/ci_checkout.sh @@ -128,6 +128,16 @@ if [ -s "${WS}/.ci-remote-tags" ]; then gitauth fetch -q --tags --depth 1 origin 2>/dev/null \ && echo "✅ 已取 tag 对象(判『注释 tag』需要它)" \ || echo "⚠️ 取 tag 对象失败:门禁的 tag 核对会明确失败,不会跳过" + # ⚠️ 开发版本号 =「自 v<正式版本> 以来的**提交数**」,浅克隆会把条数算少(会报错号)。 + # 有 tag 时就把历史补全;补不上也不影响正确性 —— version.py 在浅仓库里**拒绝算号**, + # 退回 dev+g<短SHA>(宁可没号,也不报错号)。 + if [ "$(git rev-parse --is-shallow-repository 2>/dev/null)" = "true" ]; then + if gitauth fetch -q --unshallow origin 2>/dev/null; then + echo "✅ 已补全历史(开发版本号要按 tag 以来的提交数算准)" + else + echo "⚠️ 补全历史失败 ⇒ 开发版本号退回 dev+g<短SHA>(不会算错号)" + fi + fi fi git rev-list --count HEAD > "${WS}/.ci-commits-count" 2>/dev/null || echo 1 > "${WS}/.ci-commits-count" diff --git a/CHANGELOG.md b/CHANGELOG.md index e62b706..bbfcdfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,14 @@ > > 过程记录(审查轮次、命令输出、逐条修正明细)不入本文件:归档在 `docs/archive/`, > 未合并事项在 `docs/BACKLOG.md`。 +> +> **版本怎么走**(两行就够): +> · **正式版本** = 下面那条 `## [x.y.z] - 日期`,是**发版事实**,由人批准后打注释 tag `vX.Y.Z`; +> · **开发版本自动递增** = `<正式版本的主.次>.<自那个 tag 以来的提交数>`(正式 `0.1.0` 之后第 12 个提交 = +> `0.1.12`),由 `scripts/version.py` 从 git **算出来**,**不写进本文件** ⇒ 每个提交都有自己的版本号, +> 但"发布版本"仍然只能由人定(CI 不会自动升版本、不会打 tag)。 -## [Unreleased] +## [0.1.0] - 2026-10-13 ### Added diff --git a/Dockerfile b/Dockerfile index bae2c03..acf3933 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,9 +14,10 @@ COPY internal ./internal ARG VERSION=dev ARG RELEASE_VERSION= ARG BUILD_KIND=development +ARG COMMITS_SINCE_RELEASE= ARG COMMIT_SHA=unknown ARG BUILD_TIME=unknown -RUN LDFLAGS="-X knowledge-governance/internal/version.Number=${VERSION} -X knowledge-governance/internal/version.ReleaseVersion=${RELEASE_VERSION} -X knowledge-governance/internal/version.BuildKind=${BUILD_KIND} -X knowledge-governance/internal/version.CommitSHA=${COMMIT_SHA} -X knowledge-governance/internal/version.BuildTime=${BUILD_TIME}" \ +RUN LDFLAGS="-X knowledge-governance/internal/version.Number=${VERSION} -X knowledge-governance/internal/version.ReleaseVersion=${RELEASE_VERSION} -X knowledge-governance/internal/version.BuildKind=${BUILD_KIND} -X knowledge-governance/internal/version.CommitsSinceRelease=${COMMITS_SINCE_RELEASE} -X knowledge-governance/internal/version.CommitSHA=${COMMIT_SHA} -X knowledge-governance/internal/version.BuildTime=${BUILD_TIME}" \ && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "$LDFLAGS" -o /out/api ./cmd/api \ && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "$LDFLAGS" -o /out/worker ./cmd/worker \ && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "$LDFLAGS" -o /out/release-worker ./cmd/release-worker \ diff --git a/Makefile b/Makefile index 914282a..fa6947a 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,8 @@ PY ?= python3 VERSION ?= $(shell $(PY) scripts/version.py 2>/dev/null || echo dev) RELEASE_VERSION ?= $(shell $(PY) scripts/version.py 2>/dev/null || echo) BUILD_KIND ?= development +# 自 v<正式版本> 以来的提交数(开发号 = <主>.<次>.<它>;取不到就空 ⇒ 构建侧退回 -dev+g<短SHA>) +COMMITS_SINCE_RELEASE ?= $(shell $(PY) -c "import json,subprocess;print(json.loads(subprocess.run(['$(PY)','scripts/version.py','--identity'],capture_output=True,text=True).stdout).get('commits_since_release') or '')" 2>/dev/null) COMMIT_SHA ?= $(shell git rev-parse --short=12 HEAD 2>/dev/null || echo unknown) FULL_SHA ?= $(shell git rev-parse HEAD 2>/dev/null || echo unknown) BUILD_TIME ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) @@ -84,6 +86,7 @@ version-contract: $(GO) test -ldflags "-X knowledge-governance/internal/version.Number=$(VERSION) \ -X knowledge-governance/internal/version.ReleaseVersion=$(RELEASE_VERSION) \ -X knowledge-governance/internal/version.BuildKind=$(BUILD_KIND) \ + -X knowledge-governance/internal/version.CommitsSinceRelease=$(COMMITS_SINCE_RELEASE) \ -X knowledge-governance/internal/version.CommitSHA=$(FULL_SHA)" \ -count=1 -run Test -v ./internal/version/... @@ -91,5 +94,6 @@ version-contract: # 一个显眼的 dev 好过一个看起来正常的假版本号)。compose.yaml 的 args 透传见 PR TODO。 image: docker build --build-arg VERSION=$(VERSION) --build-arg RELEASE_VERSION=$(RELEASE_VERSION) \ - --build-arg BUILD_KIND=$(BUILD_KIND) --build-arg COMMIT_SHA=$(COMMIT_SHA) \ + --build-arg BUILD_KIND=$(BUILD_KIND) --build-arg COMMITS_SINCE_RELEASE=$(COMMITS_SINCE_RELEASE) \ + --build-arg COMMIT_SHA=$(COMMIT_SHA) \ --build-arg BUILD_TIME=$(BUILD_TIME) -t knowledge-governance-p1:$(VERSION)-$(COMMIT_SHA) . \ No newline at end of file diff --git a/docs/RELEASE_RUNBOOK.md b/docs/RELEASE_RUNBOOK.md index dbb9953..4236139 100644 --- a/docs/RELEASE_RUNBOOK.md +++ b/docs/RELEASE_RUNBOOK.md @@ -46,9 +46,16 @@ python3 scripts/version.py --identity # JSON:release_version / build_kind | 情形 | `build_kind` | `release_version` | `display_version` | |---|---|---|---| -| 普通开发构建(有正式基线) | `development` | 顶部已发布版本 | `X.Y.Z-dev+g<短SHA>` | -| 普通开发构建(**还没有正式版本**) | `development` | `null` | `dev+g<短SHA>` | -| 发布构建(显式给出发布上下文) | `release` | 顶部已发布版本 | `X.Y.Z`(另带完整 SHA) | +| 普通开发构建(有正式基线,tag 也在) | `development` | 顶部已发布版本 | **`<主.次>.<自该 tag 的提交数>`**,如 `0.1.12` | +| 开发构建恰好在发布提交上(提交数 = 0) | `development` | 同左 | 就是那个正式版本号 | +| 普通开发构建(**还没有正式基线 / 取不到 tag**) | `development` | `null` | `dev+g<短SHA>` | +| 发布构建(显式发布上下文 + 证据齐) | `release` | 顶部已发布版本 | `X.Y.Z`(另带完整 SHA) | + +**开发版本自动递增规则**:`<正式版本的主.次>.<自 `v<正式版本>` 以来的提交数>` —— +第 1 个提交 `0.1.1`、第 12 个 `0.1.12`;**数字只增、每次提交必不同**,且**不写进 CHANGELOG** +(发布记录只在发版时动)。打新 tag 后从新 tag 重新起算 ⇒ 所以**下一次正式发布请把"次版本"加一** +(`0.1.x` 之后发 `0.2.0`),这样正式号永远严格大于此前出现过的所有开发号,不会撞号。 +证据字段:`commits_since_release`(自 tag 以来的提交数,无 tag 为 `null`)。 - `build_kind=release` **只能**来自显式的发布上下文(`BUILD_KIND=release`),并且**要核证据**: 完整 40 位 SHA + **指向该提交的注释 tag** `v<版本号>` + 该提交里 CHANGELOG 顶部版本一致; diff --git a/internal/version/version.go b/internal/version/version.go index 4c0d0e9..a4199b8 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -10,7 +10,7 @@ // -X knowledge-governance/internal/version.CommitSHA=$(git rev-parse HEAD) \ // -X knowledge-governance/internal/version.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" ./cmd/api // -// 四条纪律: +// 五条纪律: // // 1. **不要**把它们改成编译期常量,也**不要**另建仓库根的 VERSION 文件 —— // 版本号的唯一来源是 CHANGELOG.md,由 scripts/version.py 解析。 @@ -19,7 +19,9 @@ // 3. **开发构建不许自称正式版**:`BuildKind` 只能来自显式的发布上下文 // (`BUILD_KIND=release`);普通 main CI 永远是 development, // DisplayVersion() 必须带 `-dev+g<短SHA>`,**不允许只显示上一正式版本号**。 -// 4. 契约由本包的测试兜底:发布构建的 ReleaseVersion 必须等于 CHANGELOG.md 顶部的 +// 4. **开发号随提交递增**:`CommitsSinceRelease` 由构建期注入,DisplayVersion() 据此给出 +// `<主>.<次>.`(`0.1.12`);没注入就退回 `-dev+g<短SHA>` —— 宁可没号,也不报错号。 +// 5. 契约由本包的测试兜底:发布构建的 ReleaseVersion 必须等于 CHANGELOG.md 顶部的 // 已发布版本,开发构建必须是 dev 形状;CI 带 -ldflags 跑它并断言**没有 SKIP** // (被跳过的断言等于没有断言)。 package version @@ -27,6 +29,8 @@ package version import ( "fmt" "regexp" + "strconv" + "strings" ) // Number / ReleaseVersion / BuildKind / CommitSHA / BuildTime 由 -ldflags -X 注入; @@ -37,8 +41,26 @@ var ( BuildKind = "development" // development | release CommitSHA = "unknown" // 完整 40 位提交 SHA BuildTime = "unknown" // 构建时间(built_at) + // CommitsSinceRelease:自 `v` 以来的提交数(字符串形式的整数); + // 由构建期注入(`scripts/version.py --identity` 的 commits_since_release)。 + // 空 / 非整数 ⇒ 开发号算不出来,退回 `<版本>-dev+g<短SHA>`(**不猜号**)。 + CommitsSinceRelease = "" ) +var intRe = regexp.MustCompile(`^\d+$`) + +// commitsCount 解析注入的提交数;没注入 / 不是整数 ⇒ ok=false。 +func commitsCount() (int, bool) { + if !intRe.MatchString(CommitsSinceRelease) { + return 0, false + } + n, err := strconv.Atoi(CommitsSinceRelease) + if err != nil { + return 0, false + } + return n, true +} + var ( fullSHA = regexp.MustCompile(`^[0-9a-f]{40}$`) semver = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$`) @@ -57,17 +79,25 @@ func ShortSHA() string { // DisplayVersion 是**用户可见标识**,规则与 scripts/version.py 一致: // -// 发布构建 → 0.1.0 -// 开发构建(有基线) → 0.1.0-dev+g<短SHA> -// 开发构建(无基线) → dev+g<短SHA> -// 什么都拿不到 → dev +// 发布构建 → 0.1.0 +// 开发构建(有基线 + 计数) → **<主>.<次>.<自 v<版本> 以来的提交数>**,如 0.1.12(随提交递增) +// 开发构建(恰在发布提交上)→ 就是那个正式版本号(提交数 0) +// 开发构建(无基线/无计数)→ 0.1.0-dev+g<短SHA> / dev+g<短SHA> +// 什么都拿不到 → dev // -// ⚠️ 开发构建**不会**退化成"只显示上一正式版本号"——那正是要修的问题。 +// ⚠️ 开发构建**不会**退化成"只显示上一正式版本号"——那正是要修的问题(有提交在 tag 之后就必须带自己的号)。 func DisplayVersion() string { if IsRelease() { return ReleaseVersion } short := ShortSHA() + if n, ok := commitsCount(); ok && semver.MatchString(ReleaseVersion) { + if n == 0 { + return ReleaseVersion // HEAD 恰在发布提交上 + } + parts := strings.Split(ReleaseVersion, ".") + return fmt.Sprintf("%s.%s.%d", parts[0], parts[1], n) + } switch { case ReleaseVersion != "" && short != "": return ReleaseVersion + "-dev+g" + short diff --git a/internal/version/version_test.go b/internal/version/version_test.go index a609557..2182835 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -1,10 +1,12 @@ package version import ( + "fmt" "os" "os/exec" "path/filepath" "regexp" + "strconv" "strings" "testing" ) @@ -82,24 +84,61 @@ func TestDevBuildDoesNotClaimRelease(t *testing.T) { if BuildKind != "development" { t.Fatalf("BuildKind %q 既不是 development 也不是 release", BuildKind) } - if !strings.Contains(disp, "dev") { - t.Fatalf("开发构建的 DisplayVersion %q 没有 dev 标识 —— 开发构建不能只显示正式版本号", disp) - } if strings.Contains(disp, "+g") && ShortSHA() == "" { t.Fatalf("DisplayVersion %q 带了 +g 但 CommitSHA %q 不是有效 SHA", disp, CommitSHA) } - switch { - case want != "" && ShortSHA() != "": - if want := want + "-dev+g" + ShortSHA(); disp != want { - t.Fatalf("开发构建 DisplayVersion %q,期望 %q", disp, want) + if want == "" { + // 还没有正式基线 ⇒ 只能是 dev 形状 + if ShortSHA() != "" && disp != "dev+g"+ShortSHA() { + t.Fatalf("无正式基线时 DisplayVersion %q,期望 dev+g%s", disp, ShortSHA()) } - case want == "" && ShortSHA() != "": - if want := "dev+g" + ShortSHA(); disp != want { - t.Fatalf("无正式基线的开发构建 DisplayVersion %q,期望 %q", disp, want) + return + } + // 有正式基线:开发号 = <主>.<次>.<自 v<版本> 以来的提交数>;浅仓库/取不到 tag ⇒ 回退 dev 形状 + n, ok := commitsSinceTag(want) + if !ok { + if disp != want+"-dev+g"+ShortSHA() { + t.Fatalf("取不到 tag 时 DisplayVersion %q,期望 %s-dev+g%s", disp, want, ShortSHA()) + } + return + } + parts := strings.Split(want, ".") + exp := want + if n > 0 { + exp = fmt.Sprintf("%s.%s.%d", parts[0], parts[1], n) + } + if disp != exp { + t.Fatalf("开发版本号 %q,期望 %q(= <主>.<次>.<自 v%s 以来的提交数 %d>)", disp, exp, want, n) + } + // 交叉核对:注入进来的提交数必须真的等于 git 算出的(防注入错号) + if injected, ok2 := commitsCount(); ok2 && injected != n { + t.Fatalf("注入的 CommitsSinceRelease=%d 与 git 算出的 %d 不一致", injected, n) + } + if disp != want { + // 不许"只显示上一正式版本号":有提交在 tag 之后,就必须带自己的号 + if n == 0 { + t.Fatalf("tag 之后还有提交却仍显示正式版本号:%q", disp) } } } +// commitsSinceTag 用 git 算「自 v<版本> 以来的提交数」;浅仓库 / 取不到 tag ⇒ ok=false(不许猜)。 +func commitsSinceTag(ver string) (int, bool) { + if out, err := exec.Command("git", "rev-parse", "--is-shallow-repository").Output(); err == nil && + strings.TrimSpace(string(out)) == "true" { + return 0, false + } + out, err := exec.Command("git", "rev-list", "--count", "v"+ver+"..HEAD").Output() + if err != nil { + return 0, false + } + n, convErr := strconv.Atoi(strings.TrimSpace(string(out))) + if convErr != nil { + return 0, false + } + return n, true +} + // 正式构建必须**有证据**:完整提交 SHA + 一个指向该提交的**注释 tag** `v`。 // 光有"CHANGELOG 顶部有版本号"不算 —— 那会把每个开发构建都说成正式版。 func TestReleaseBuildRequiresEvidence(t *testing.T) { diff --git a/scripts/ci_checks.py b/scripts/ci_checks.py index 1ca64b8..e13a02a 100644 --- a/scripts/ci_checks.py +++ b/scripts/ci_checks.py @@ -311,6 +311,8 @@ def ldflags_for(ident: dict, *, build_kind: str | None = None, f"-X knowledge-governance/internal/version.Number={rel or 'dev'}", f"-X knowledge-governance/internal/version.ReleaseVersion={rel or ''}", f"-X knowledge-governance/internal/version.BuildKind={kind}", + f"-X knowledge-governance/internal/version.CommitsSinceRelease=" + f"{'' if ident.get('commits_since_release') is None else ident['commits_since_release']}", f"-X knowledge-governance/internal/version.CommitSHA={ident.get('commit_sha') or 'unknown'}", f"-X knowledge-governance/internal/version.BuildTime={ident.get('built_at') or 'unknown'}", ]) diff --git a/scripts/ci_negative_fixtures.sh b/scripts/ci_negative_fixtures.sh index 93938c4..1987d4a 100644 --- a/scripts/ci_negative_fixtures.sh +++ b/scripts/ci_negative_fixtures.sh @@ -92,16 +92,17 @@ pycheck() { # pycheck "<名字>" <<'PY' … PY local name="$1"; local out; out="$("$PY" - 2>&1)"; local rc=$? if [ "$rc" = 0 ]; then ok "$name"; else no "$name"; echo "$out" | tail -8 | sed 's/^/ /'; fi } -pycheck "无正式基线时 --identity 给出 dev+g<短SHA>(不是正式版本号)" <<'PY' +pycheck "有正式基线但取不到 tag ⇒ 退回 <版本>-dev+g<短SHA>(不猜开发号)" <<'PY' import json, re, subprocess p = subprocess.run(["python3", "scripts/version.py", "--identity"], capture_output=True, text=True) if p.returncode: - print("❌ --identity 在无基线时失败:", (p.stderr or "").strip()[:200]); raise SystemExit(1) + print("❌ --identity 失败:", (p.stderr or "").strip()[:200]); raise SystemExit(1) d = json.loads(p.stdout) -okv = (d["release_version"] is None and d["build_kind"] == "development" - and re.fullmatch(r"dev\+g[0-9a-f]{12}", d["display_version"] or "") - and len(d["commit_sha"]) == 40) -print(("✅ " if okv else "❌ ") + f"显示版本 {d['display_version']}|release_version={d['release_version']}|{d['build_kind']}") +rel = d["release_version"] +okv = (d["build_kind"] == "development" and len(d["commit_sha"]) == 40 + and d["commits_since_release"] is None + and re.fullmatch(re.escape(rel) + r"-dev\+g[0-9a-f]{12}", d["display_version"] or "")) +print(("✅ " if okv else "❌ ") + f"显示版本 {d['display_version']}|release_version={rel}|提交数={d['commits_since_release']}") raise SystemExit(0 if okv else 1) PY pycheck "没有已发布版本时不许声明发布构建(--identity --release ⇒ rc=2)" <<'PY' @@ -129,6 +130,8 @@ def seed(d): (d / "internal" / "version").mkdir(parents=True) for f in SRC.glob("*.go"): shutil.copy(f, d / "internal" / "version" / f.name) + (d / "scripts").mkdir(exist_ok=True) + shutil.copy("scripts/version.py", d / "scripts" / "version.py") (d / "go.mod").write_text("module knowledge-governance\n\ngo 1.21\n", encoding="utf-8") (d / "CHANGELOG.md").write_text( "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- x\n\n" @@ -144,9 +147,17 @@ def seed(d): text=True).stdout.strip() def run(d, kind, rel, sha): + import json as _json + n = "" + ident = subprocess.run(["python3", "scripts/version.py", "--identity"], cwd=d, + capture_output=True, text=True) + if ident.returncode == 0: + val = _json.loads(ident.stdout).get("commits_since_release") + n = "" if val is None else str(val) ld = (f"-X knowledge-governance/internal/version.Number={rel or 'dev'} " f"-X knowledge-governance/internal/version.ReleaseVersion={rel} " f"-X knowledge-governance/internal/version.BuildKind={kind} " + f"-X knowledge-governance/internal/version.CommitsSinceRelease={n} " f"-X knowledge-governance/internal/version.CommitSHA={sha}") return subprocess.run([GO, "test", "-count=1", "-run", "Test", f"-ldflags={ld}", "./internal/version/..."], cwd=d, capture_output=True, text=True) @@ -172,8 +183,23 @@ with tempfile.TemporaryDirectory() as tmp: cases.append(("轻量 tag ⇒ 红(不算发布证据)", p.returncode != 0, (p.stdout + p.stderr))) # 开发构建:ReleaseVersion 要等于该提交里 CHANGELOG 顶部的版本(注入口径),显示带 dev 标识 p = run(d, "development", "0.2.0", sha) - cases.append(("开发构建(有基线、带完整 SHA)⇒ 绿(显示 X.Y.Z-dev+g<短SHA>,不是只显示正式号)", + cases.append(("开发构建(tag 就在该提交上)⇒ 绿(开发号 == 正式号 0.2.0)", p.returncode == 0, (p.stdout + p.stderr))) + # tag 之后再落 2 个提交 ⇒ 开发号必须是 0.2.2(随提交递增) + for i in (1, 2): + (d / f"more{i}.txt").write_text("x", encoding="utf-8") + subprocess.run(["git", "add", "-A"], cwd=d, check=True, capture_output=True) + subprocess.run(["git", "commit", "-q", "-m", f"chore: 追加 {i}"], cwd=d, check=True, + capture_output=True) + sha2 = subprocess.run(["git", "rev-parse", "HEAD"], cwd=d, capture_output=True, + text=True).stdout.strip() + p = run(d, "development", "0.2.0", sha2) + got = subprocess.run(["python3", "scripts/version.py", "--identity"], cwd=d, + capture_output=True, text=True) + import json as _json + disp = _json.loads(got.stdout).get("display_version") if got.returncode == 0 else "?" + cases.append((f"tag 之后第 2 个提交 ⇒ 开发号 0.2.2(实际 {disp})", + p.returncode == 0 and disp == "0.2.2", (p.stdout + p.stderr))) bad = [n for n, okv, _ in cases if not okv] for n, okv, tail in cases: @@ -652,10 +678,15 @@ if [ "$rc" -ne 0 ] && grep -q "现在\*\*不能\*\*发" /tmp/kg-pf.log; then else no "脏工作区下预检竟然给了可发结论(退出码 $rc)"; tail -6 /tmp/kg-pf.log | sed 's/^/ /' fi -if grep -q "还没有已发布版本:首个正式版本号必须由人显式指定" /tmp/kg-pf.log; then - ok "没有版本基线时,预检把「首个版本号由人指定」列成阻塞项(不编一个版本号就发)" +if grep -q "首个正式版本号必须由人显式指定" /tmp/kg-pf.log; then + no "本仓已有已发布版本(0.1.0),不该再报「首个版本号由人指定」" else - no "预检没有把「首个版本号由人指定」列成阻塞项"; tail -6 /tmp/kg-pf.log | sed 's/^/ /' + ok "本仓已有已发布版本 ⇒ 不再报「首个版本号由人指定」(无基线时报的那条由 --self-test 覆盖)" +fi +if grep -q "开发号" /tmp/kg-pf.log; then + ok "预检会打印当前开发号($(grep -o "当前开发号 : .*" /tmp/kg-pf.log | head -1 | cut -c1-46))" +else + no "预检没有打印当前开发号"; tail -6 /tmp/kg-pf.log | sed 's/^/ /' fi $PY scripts/release_preflight.py --no-api >/tmp/kg-pf-noapi.log 2>&1; rc=$? if [ "$rc" -ne 0 ] && grep -q "未核对" /tmp/kg-pf-noapi.log; then @@ -720,7 +751,7 @@ with tempfile.TemporaryDirectory() as tmp: # 候选版本必须“**是**目标提交里的顶部版本”:只在历史里出现过不算 d2 = pathlib.Path(tmp) / "r2"; d2.mkdir() (d2 / "scripts").mkdir(parents=True); (d2 / "docs").mkdir(); (d2 / ".gitea").mkdir() - for n in ("check_changelog.py", "release_preflight.py"): + for n in ("check_changelog.py", "release_preflight.py", "version.py"): (d2 / "scripts" / n).write_text(pathlib.Path("scripts", n).read_text(encoding="utf-8"), encoding="utf-8") (d2 / "docs" / "RELEASE_RUNBOOK.md").write_text("# 发布说明\n", encoding="utf-8") @@ -736,6 +767,14 @@ with tempfile.TemporaryDirectory() as tmp: "还没写进目标提交" in t3)) _, t4 = pre(d2, "--version", "0.3.0") cases.append(("候选版本就是该提交的**顶部**版本 ⇒ 该条阻塞消失", "还没写进目标提交" not in t4)) + # 打上注释 tag 后:开发号 = 0.3.0(tag 就在该提交上)⇒ 候选 0.3.0 会被"开发号"判据拦住 + subprocess.run(["git", "tag", "-a", "v0.3.0", "-m", "release 0.3.0"], cwd=d2, check=True, + capture_output=True) + _, t5 = pre(d2, "--version", "0.3.0") + cases.append(("候选正式版本不大于当前开发号 ⇒ 阻断(不许把开发号当正式号)", + "不大于当前开发号" in t5)) + _, t6 = pre(d2, "--version", "0.4.0") + cases.append(("候选正式版本 > 开发号(次版本加一)⇒ 该条不拦", "不大于当前开发号" not in t6)) bad = [n for n, okv in cases if not okv] for n, okv in cases: diff --git a/scripts/release_preflight.py b/scripts/release_preflight.py index fb174a5..c297034 100644 --- a/scripts/release_preflight.py +++ b/scripts/release_preflight.py @@ -39,6 +39,7 @@ CHANGELOG = ROOT / "CHANGELOG.md" FULL_SHA = re.compile(r"^[0-9a-f]{40}$") RELEASE_HEADING = re.compile(r"^##\s*\[(\d+\.\d+\.\d+)\]", re.MULTILINE) SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") +SIGNUM = re.compile(r"^\d+\.\d+\.\d+$") # 预检路径允许的只读 git 子命令(写命令在这里直接拒) GIT_READ_OK = {"ls-remote", "rev-parse", "log", "show", "cat-file", "for-each-ref", "status", "diff", "describe", "tag", "remote"} @@ -188,6 +189,30 @@ def release_state(status: int) -> str: return "error" +def current_dev_number() -> tuple[str | None, int | None]: + """当前开发号(`<主>.<次>.<自 tag 的提交数>`)与提交数;取不到 ⇒ (None, None)。 + + 直接复用 `scripts/version.py` 的身份计算(同一份规则,不另写一套)。 + """ + try: + sys.path.insert(0, str(ROOT / "scripts")) + import version as ver # type: ignore # noqa: PLC0415 + + ident = ver.build_identity()[0] + n = ident.get("commits_since_release") + dev = ident.get("display_version") + if ident.get("build_kind") == "development" and dev and SIGNUM.match(str(dev)): + return str(dev), (int(n) if isinstance(n, int) else None) + return None, (int(n) if isinstance(n, int) else None) + except Exception: + return None, None + + +def _ver_tuple(v: str) -> tuple[int, int, int]: + a, b, c = (int(x) for x in v.split(".")) + return a, b, c + + def readiness(f: dict) -> tuple[bool, list[str], list[str]]: """把**事实**翻成(是否就绪, 阻塞项, 准备建议)——纯函数,可用假事实直接自检。 @@ -209,6 +234,17 @@ def readiness(f: dict) -> tuple[bool, list[str], list[str]]: f"—— 版本记录要先提交,再发版") advice.append(f"把 `## [Unreleased]` 改成 `## [{cand}] - YYYY-MM-DD`(四类小节、" f"破坏性变化带发布文档链接)并**提交**,然后重跑预检") + dev = f.get("dev_number") + if dev and SEMVER.match(str(f.get("candidate") or "")): + try: + if _ver_tuple(str(f["candidate"])) <= _ver_tuple(str(dev)): + blockers.append(f"候选正式版本 {f['candidate']} 不大于当前开发号 {dev}" + f"—— 会把已经出现过的开发号当成正式版(开发号随提交递增," + f"发布请把次版本加一,例如 " + f"{str(dev).split('.')[0]}.{int(str(dev).split('.')[1]) + 1}.0)") + advice.append("下一次正式发布建议用 <主>.<次+1>.0(保证正式号严格大于此前所有开发号)") + except ValueError: + pass if f.get("candidate_tagged"): blockers.append(f"远端已经有 tag v{cand} ⇒ 不能覆盖已发布的 tag(版本号只增不减)") if f.get("dirty"): @@ -334,6 +370,9 @@ def main(argv: list[str]) -> int: # ── 事实收集(只读)────────────────────────────────────────────── tag = f"v{candidate}" required, policy_src = required_contexts() + dev_number, dev_commits = current_dev_number() + print(f" 当前开发号 : {dev_number or '(无正式基线 / 取不到 tag ⇒ dev+g<短SHA>)'}" + f"{f'(自 v{current} 起 {dev_commits} 个提交)' if (dev_number and dev_commits is not None) else ''}") top_in_commit = top_version_in_commit(want_sha) in_target = top_in_commit == candidate mark = "✅ 就是顶部" if in_target else "❌ 不是顶部" @@ -397,7 +436,7 @@ def main(argv: list[str]) -> int: facts = { "candidate": candidate, "tag": tag, "first_release": first_release, "explicit_version": bool(args.version), "in_target_commit": in_target, - "top_in_commit": top_in_commit, + "top_in_commit": top_in_commit, "dev_number": dev_number, "dirty": len(dirty_lines), "head_full": bool(FULL_SHA.match(head)), "sha_is_head": want_sha == head, "checker_ok": checker_ok, "unreleased_present": bool(unreleased.strip()), @@ -481,6 +520,11 @@ def self_test() -> int: first_release=True, explicit_version=False) rd("首发**显式**指定且记录已进提交 ⇒ 就绪", True, first_release=True, explicit_version=True, contexts={"build / build (push)": "success"}) + rd("候选正式版本不大于当前开发号 ⇒ 不就绪(不许把开发号当正式号)", + False, "不大于当前开发号", candidate="0.1.5", dev_number="0.1.12", in_target_commit=True) + rd("候选正式版本 > 当前开发号 ⇒ 该条不拦(次版本加一)", + True, "", candidate="0.2.0", dev_number="0.1.12", in_target_commit=True, + contexts={"build / build (push)": "success"}) rd("候选版本没进目标提交 ⇒ 不就绪", False, "还没写进目标提交", in_target_commit=False) rd("候选版本只在历史里(顶部是别的版本)⇒ 不就绪,且信息里给出真实顶部版本", False, "顶部**已发布版本是 `## [0.3.0]`", @@ -595,7 +639,7 @@ def self_test() -> int: print(f" · {p}", file=sys.stderr) return 1 print("✅ 预检自检通过:写类 git 命令与 POST/PUT/DELETE/PATCH 在预检路径上全部被拒;版本推进正确;" - "就绪判定 19 个分支(context 缺项/pending/failure/查询异常、无策略、Release 非 404、" + "就绪判定 21 个分支(context 缺项/pending/failure/查询异常、无策略、Release 非 404、" "tag 占用、未核对远端、首发未显式指定、记录未进提交…逐条阻断;证据齐才就绪);" "『顶部版本才算数』与『写进提交才算数』用临时仓库验证;" "`--execute` 明确拒绝且零写操作、零 API 调用") diff --git a/scripts/version.py b/scripts/version.py index c7ba436..3e812b3 100755 --- a/scripts/version.py +++ b/scripts/version.py @@ -18,12 +18,19 @@ CI 只做**核验**(tag 版本 == 它指到那个提交里的版本记录) 完整 40 位 SHA + 一个**指向该提交的合规注释 tag** `v<版本号>` + 该提交里 CHANGELOG 顶部版本一致; 缺任何一条 ⇒ **拒绝标为正式版**(rc=2,JSON 里带 `release_blocked` 说明缺什么)。 +**开发版本自动递增(零人工)**:`<正式版本的主.次>.<自 v<正式版本> 以来的提交数>` —— +例:正式 `0.1.0` 之后第 1 个提交 = `0.1.1`,第 12 个 = `0.1.12`;打新 tag 后从新 tag 重新起算。 +它**不写进 CHANGELOG**(发布记录只在发版时动),而是从 git 算出来 ⇒ 每次提交必然不同、数字只增。 +自动升的是**开发标识**,不是发布版本:正式版本与 tag 仍需人批准(规范禁止 CI 自动打 tag)。 + 构建身份字段(`--identity`): release_version 正式版本基线;本仓还没有正式发布过 ⇒ `null` build_kind development | release(**只有显式声明**才是 release,普通 main CI 永远不是) commit_sha 完整 40 位提交 SHA(取不到 ⇒ `unknown`) - display_version 用户可见标识:`X.Y.Z` / `X.Y.Z-dev+g<短SHA>` / `dev+g<短SHA>` / `dev` + display_version 用户可见标识:`X.Y.Z`(正式)/ `<主.次>.<提交数>`(开发,随提交递增)/ + `dev+g<短SHA>`(还没有正式基线)/ `dev`(连 SHA 都取不到) built_at 构建时间(`BUILT_AT` 环境变量,缺省取当前 UTC) + commits_since_release 自 `v` 以来的提交数(没有 tag ⇒ null) ⚠️ 开发构建**不能**只显示上一正式版本号(那正是要修的问题),也不许自称正式版。 """ @@ -61,10 +68,42 @@ def git_sha(root: Path = ROOT) -> str: return s if FULL_SHA.match(s) else "unknown" -def display_version(release_version: str | None, kind: str, sha: str) -> str: +def commits_since_release(release_version: str | None, root: Path = ROOT) -> int | None: + """自 `v` 那个 tag 以来的提交数(含 HEAD;HEAD 恰在 tag 上 ⇒ 0)。 + + 取不到 tag(还没发布过 / 检出没取 tag)⇒ None —— 这时退回 `dev+g<短SHA>`,不猜号。 + ⚠️ **浅克隆里不算**:`--depth` 截断的历史会把条数算少 ⇒ 宁可退回 `dev+g<短SHA>`, + 也不报一个"看起来正常但其实错"的开发号(检出步骤会在有 tag 时补全历史,见 `ci_checkout.sh`)。 + """ + if not release_version: + return None + rc, sh = _git("rev-parse", "--is-shallow-repository", cwd=root) + if rc == 0 and sh.strip() == "true": + return None + rc, out = _git("rev-list", "--count", f"v{release_version}..HEAD", cwd=root) + t = out.strip() + return int(t) if rc == 0 and t.isdigit() else None + + +def dev_version(release_version: str | None, n: int | None) -> str | None: + """开发版本号:`<主>.<次>.`;`n==0` ⇒ 就是发布提交本身(返回正式版本号)。""" + if not release_version or n is None: + return None + if n == 0: + return release_version + a, b, _c = release_version.split(".") + return f"{int(a)}.{int(b)}.{n}" + + +def display_version(release_version: str | None, kind: str, sha: str, + n: int | None = None) -> str: short = sha[:12] if FULL_SHA.match(sha) else "" if kind == "release" and release_version: return release_version + # 开发构建:有正式基线就报"随提交递增"的开发号(无 tag/取不到计数才退回 dev+g<短SHA>) + dev = dev_version(release_version, n) + if dev: + return dev if release_version and short: return f"{release_version}-dev+g{short}" if release_version: @@ -123,13 +162,15 @@ def build_identity(*, release: bool = False, built_at: str | None = None, """构建身份 + 证据缺失项。`release=True` 但证据不齐 ⇒ 身份退回 development 并给出原因。""" rel = current_version(root / "CHANGELOG.md") sha = sha if sha is not None else git_sha(root) + n = commits_since_release(rel, root) problems = release_evidence(rel, sha, root) if release else [] kind = "release" if (release and not problems) else "development" ident = { "release_version": rel, "build_kind": kind, "commit_sha": sha, - "display_version": display_version(rel, kind, sha), + "commits_since_release": n, + "display_version": display_version(rel, kind, sha, n), "built_at": built_at or os.environ.get("BUILT_AT") or os.environ.get("BUILD_TIME") or dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), } @@ -230,11 +271,29 @@ def self_test() -> int: case("声称版本与提交内记录不一致 ⇒ 不标正式", ident6["build_kind"] == "development" and any("不一致" in x for x in blocked6), f"{blocked6}") - # ⑦ 开发构建:有基线 ⇒ X.Y.Z-dev+g<短SHA>;无基线 ⇒ dev+g<短SHA> - dev, _ = build_identity(root=d, sha=sha, built_at="t") - case("开发构建(有基线)= 0.2.0-dev+g<短SHA>", - dev["build_kind"] == "development" and dev["display_version"] == f"0.2.0-dev+g{sha[:12]}", - f"{dev['display_version']!r}") + # ⑥b 开发号随提交递增:临时仓库里打 tag 后再加 2 个提交 ⇒ 0.1.2;恰在 tag 上 ⇒ 0.1.0 + d8, _, env8 = _tmp_repo(); dirs.append(d8) + subprocess.run(["git", "tag", "-a", "v0.2.0", "-m", "release 0.2.0"], cwd=d8, env=env8, check=True) + at_tag, _ = build_identity(root=d8, built_at="t") + case("恰在发布提交上 ⇒ 显示正式版本号 0.2.0", + at_tag["display_version"] == "0.2.0" and at_tag["commits_since_release"] == 0, f"{at_tag}") + for i in (1, 2): + (d8 / f"f{i}.txt").write_text("x", encoding="utf-8") + subprocess.run(["git", "add", "-A"], cwd=d8, env=env8, check=True) + subprocess.run(["git", "commit", "-q", "-m", f"chore: 第 {i} 个提交"], cwd=d8, env=env8, check=True) + after, _ = build_identity(root=d8, built_at="t") + case("tag 之后第 2 个提交 ⇒ 开发号 0.2.2(随提交递增)", + after["display_version"] == "0.2.2" and after["commits_since_release"] == 2, f"{after}") + case("tag 之后不再等于正式版本号(不许只显示上一正式号)", + after["display_version"] != after["release_version"], f"{after}") + + # ⑦ 开发构建(取不到 tag 时):有基线 ⇒ X.Y.Z-dev+g<短SHA>;无基线 ⇒ dev+g<短SHA> + # 有基线但**一个 tag 都没有** ⇒ 退回 `<版本>-dev+g<短SHA>`(不猜号) + d9, sha9, _env9 = _tmp_repo(); dirs.append(d9) + dev, _ = build_identity(root=d9, sha=sha9, built_at="t") + case("有基线但取不到 tag = 0.2.0-dev+g<短SHA>(不猜号)", + dev["build_kind"] == "development" and dev["commits_since_release"] is None + and dev["display_version"] == f"0.2.0-dev+g{sha9[:12]}", f"{dev}") d7, sha7, _ = _tmp_repo("# Changelog\n\n## [Unreleased]\n\n### Added\n\n- x\n"); dirs.append(d7) dev7, _ = build_identity(root=d7, sha=sha7, built_at="t") case("开发构建(无基线)= dev+g<短SHA>", @@ -251,7 +310,8 @@ def self_test() -> int: return 1 print("✅ 构建身份自检通过:证据齐(注释 tag + 完整 SHA + 提交内版本一致)才是正式构建;" "无 tag / unknown 或短 SHA / tag 指错提交 / 轻量 tag / 版本不一致 逐条拒绝;" - "开发构建带显式 dev 标识") + "开发号随提交递增(tag 后第 2 个提交 = 0.2.2,恰在发布提交上 = 0.2.0)," + "取不到 tag / 浅克隆时不猜号(退回 dev+g<短SHA>)") return 0