按使用者要求定的版本规则(自动升的是**开发标识**,发布版本与 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:
@@ -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
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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 顶部版本一致;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'}",
|
||||
])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user