feat(version): 开发号随提交自动递增 + 首个正式版本 0.1.0
build / build (push) Successful in 51s

按使用者要求定的版本规则(自动升的是**开发标识**,发布版本与 tag 仍由人批准):

- **开发号自动递增**:display_version(development) = `<正式版本的主.次>.<自 v<正式版本> 以来的提交数>`
  (正式 0.1.0 之后第 1 个提交 0.1.1、第 12 个 0.1.12;恰在发布提交上 = 正式号)。
  它**不写进 CHANGELOG**(发布记录只在发版时动),由 git 算出来 ⇒ 每个提交都有自己的号、数字只增。
- **浅克隆不猜号**:`--depth` 会把条数算少 ⇒ 浅仓库里拒绝算号,退回 `<版本>-dev+g<短SHA>`;
  检出步骤在仓库有 tag 时 `--unshallow` 补全历史。
- 接线三处齐全:`version.py --identity` → 门禁 `-X …CommitsSinceRelease=<n>`;Makefile 的
  `COMMITS_SINCE_RELEASE`;Dockerfile 同名 build-arg;Go 侧新增 `CommitsSinceRelease` 与
  DisplayVersion() 新规则;契约测试**交叉核对**"注入的提交数 == git 算出的"(防注入错号)。
- **首个正式版本 0.1.0**:CHANGELOG 的 `[Unreleased]` 提成 `## [0.1.0] - 2026-10-13`;
  头部补"版本怎么走"两行;RELEASE_RUNBOOK 补递增规则与"下一次发布把次版本加一"。
- 发版预检新增:打印**当前开发号**,并挡住"候选正式版本 <= 当前开发号"(会把开发号当正式号;建议次版本加一)。

验证:version.py --self-test(含 tag 后第 2 个提交 = 0.2.2、恰在 tag 上 = 0.2.0、无 tag 不猜号);
release_preflight --self-test(21 分支,含开发号两条);go test 本地三态 + 临时仓库 tag+2 提交实测
(Python 与 Go 都得出 0.2.2);反向夹具 85/0;make verify 15/15 success。
This commit is contained in:
t
2026-10-13 16:45:56 +08:00
parent 97f4b29661
commit 6de5d52363
11 changed files with 287 additions and 45 deletions
+10
View File
@@ -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"
+7 -1
View File
@@ -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
+2 -1
View File
@@ -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 \
+5 -1
View File
@@ -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) .
+10 -3
View File
@@ -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 顶部版本一致;
+37 -7
View File
@@ -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() 据此给出
// `<主>.<次>.<n>`(`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<ReleaseVersion>` 以来的提交数(字符串形式的整数);
// 由构建期注入(`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
+49 -10
View File
@@ -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<ReleaseVersion>`。
// 光有"CHANGELOG 顶部有版本号"不算 —— 那会把每个开发构建都说成正式版。
func TestReleaseBuildRequiresEvidence(t *testing.T) {
+2
View File
@@ -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'}",
])
+50 -11
View File
@@ -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:
+46 -2
View File
@@ -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 调用")
+69 -9
View File
@@ -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<release_version>` 以来的提交数(没有 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<release_version>` 那个 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>`;`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