feat(version): 版本号唯一来源 = CHANGELOG.md,并要求写清改了什么与怎么验收
build / build (push) Successful in 26s

背景:本仓此前没有任何版本/构建元信息 —— 没有 ldflags 注入、也没有产物可读的
版本号,"线上跑的是哪个提交"答不出来;"这次改了什么、怎么验收"也没有载体。

落地:
- CHANGELOG.md:**版本号的唯一来源**(正本 §6.1:版本号只准有一处)。刻意不放
  VERSION 文件、也不靠 git tag —— CI 是 `git fetch --depth 50`,取不到 tag,
  `git describe` 会静默退化。每个已发布版本必须有两节:### 改了什么 与 ### 怎么验收。
- scripts/version.py:从 CHANGELOG 顶部解析版本号;解析不到即非 0 退出(构建据此失败)。
- scripts/check_changelog.py:结构自检(两节齐、带日期、版本号不重复且严格递减、
  小节不为空),带 --self-test 反向自检。
- internal/version:三个构建期注入变量 + 契约测试(注入的号必须等于 CHANGELOG 顶部)。
- Makefile:build 带 -ldflags 注入;verify 增加 changelog-check 与 version-contract。
- Dockerfile:ARG VERSION/COMMIT_SHA/BUILD_TIME + ldflags(未透传时落 dev —— 故意显眼,
  好过看起来正常的假版本号)。
- CI:新增「版本与变更记录契约」步,并显式断言契约测试真的 PASS(被跳过的断言等于没有断言)。
- docs/提交规范.md(ci_gate.py 的报错文案一直在指它)、docs/agents/traps.md(7 条实测陷阱)。

验证(都真跑过):
- make version → 0.1.0;make changelog-check → 合规
- make version-contract → PASS;注入 9.9.9(CHANGELOG 里没有)→ FAIL(反向自检)
- 删掉真实 CHANGELOG 的「怎么验收」小节 → 报红(对真实文件做,不只是夹具)
- go build ./... 与 go vet ./internal/version/... 通过

未做(会与在审 PR #2 冲突,待其合并后在 main 上补):
- GET /version 端点(internal/httpapi/server.go)与 cmd/api 的 -version 开关
- compose.yaml 透传 build args(当前由 compose 构建的镜像会报 dev)

⚠️ 未跑 make verify 全量(含 go test ./... 与前端),CI 是本次的测试证据。
This commit is contained in:
姚章浩
2026-10-10 17:53:51 +08:00
parent 63db2b8d1e
commit 5f3a71cade
10 changed files with 631 additions and 9 deletions
+27
View File
@@ -113,6 +113,33 @@ jobs:
echo "构建结果: $(cat /tmp/ci-build-status)(识别:$ran)"
[ "$fail" -eq 0 ] || exit 1
- name: 版本与变更记录契约
run: |
set -eu
export PATH=/opt/citools/go/bin:/opt/citools/bin:$PATH
export GOCACHE=/opt/citools-cache/gocache GOMODCACHE=/opt/citools-cache/gomod GOPATH=/opt/citools-cache/gopath
export GOPROXY=https://goproxy.cn,direct GOTOOLCHAIN=local GOFLAGS=-mod=mod
# ① 变更记录结构:每个已发布版本都必须有「改了什么」「怎么验收」两节
python3 scripts/check_changelog.py
# ② 版本号的唯一来源
V="$(python3 scripts/version.py)"
echo "CHANGELOG 顶部版本: $V"
# ③ 契约:注入进二进制的号必须等于它
OUT="$(go test -ldflags "-X knowledge-governance/internal/version.Number=$V" \
-run TestNumberMatchesChangelog -v ./internal/version/... 2>&1)"
printf '%s\n' "$OUT"
# ⚠️ 被跳过的断言等于没有断言:这里显式断言它真的 PASS 了。
# 用 case 而不是 `echo | grep -q` —— 后者在 pipefail 下会被 SIGPIPE
# 谎报失败(docs/agents/traps.md trap-7)。
case "$OUT" in
*"--- PASS: TestNumberMatchesChangelog"*) echo "✅ 版本契约成立:注入的版本 == CHANGELOG 顶部" ;;
*) echo "❌ 版本契约没有真正执行(被跳过或没跑到)—— 缺 -ldflags,或 CHANGELOG 解析失败"; exit 1 ;;
esac
- name: 发布网页预览
if: always()
run: |
+75
View File
@@ -0,0 +1,75 @@
# 变更记录
> **这个文件就是版本号的唯一来源。**
> 正本 §3.1 要求"版本号只有一个来源",所以这里**不放** `VERSION` 文件,
> 也**不靠 git tag**(CI 是 `git fetch --depth 50`,取不到 tag ⇒ `git describe` 会失败)。
>
> 用法与断言(都可复现,别手抄版本号):
>
> ```bash
> python3 scripts/version.py # 打印顶部那个「已发布」版本号
> python3 scripts/check-changelog.py # 结构自检:每个版本都要有「改了什么」「怎么验收」
> ```
>
> 构建时用 `-ldflags "-X knowledge-governance/internal/version.Number=<号>"` 注入,
> CI 断言"二进制报出的版本 == 本文件顶部"。**没有条目 ⇒ 解析失败 ⇒ 构建红**,
> 所以"忘了记变更"这件事过不去。
>
> **每个已发布版本必须有两节**:`### 改了什么` 与 `### 怎么验收`。
> 缺任一节 `check-changelog.py` 报红(CI 会跑它)。
>
> 纪律:最新版本在最上面;版本号**只增不减**(回退走部署层,不改这个文件);
> 日期写 `YYYY-MM-DD`;`## [Unreleased]` 段可以只有「改了什么」。
## [Unreleased]
### 改了什么
- 待合并:PR #2 —— 批量发布 / 建批即锁定 / 需下架逐条移除 / 数据源勾选改造(23 个提交,80 个文件)
- 待合并:仓库卫生 —— 撤出误入库的 `node_modules/.vite/.../results.json` 与 8 个历史 `scripts/__pycache__/*.pyc`
## [0.1.0] - 2026-10-10
### 改了什么
**初始基线(补记)**:本仓此前既没有版本号、也没有变更记录,本次首次建立。
下面按**已上线能力**归纳,不逐条追溯历史提交。
- **来源与目录**:列出可选的钉钉知识库 → 勾选 → 变成来源;快照与变更集
- **发布管理**:计划 → 建批 → 锁定 → 授权发布(落 WeKnora 生产)
- **监控 + 需下架车道**:测试库知识逐条下架
- **钉钉侧**:知识库发现 / 注册、取件走 dws;登录与系统设置
- **运行形态**:`knowledge-governance-p1-*` —— api / web / worker / release-worker /
dingtalk-login / postgres 共 6 个容器;web 发布在 `:8088`
### 怎么验收
**冒烟(约 2 分钟;下面每条都是本机运行栈的实测结果,不是设计意图)**
```bash
# 1) 容器都在(期望 6 个:api / web / worker / release-worker / dingtalk-login / postgres)
docker ps --format '{{.Names}}' | grep knowledge-governance-p1-
# 2) 存活与就绪(web 的 nginx 反代 /health 与 /api/ 到 api 容器)
curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8088/health # 期望 200
curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8088/livez # 期望 200
curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8088/readyz # 期望 200
curl -fsS http://127.0.0.1:8088/readyz | head -c 400 # 期望 postgres / queue 非 blocked
# 3) 控制台能打开
curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8088/ # 期望 200
# 4) api 容器自带 healthcheck 的状态
docker inspect knowledge-governance-p1-api-1 --format '{{.State.Health.Status}}' # 期望 healthy
```
**业务流程(逐条走,每条都写了"操作 → 期望现象")**
- `docs/USER_FLOWS_ACCEPTANCE.md` —— C1 ~ C9
- `docs/OPERATIONS_RUNBOOK.md` —— 现行用户操作流程与运维步骤
⚠️ **冒烟通过 ≠ 验收通过**。面向上线的那一次验收必须走完 C1~C9,由人确认并留下记录;
"服务健康"只是可继续验收的前提,不是验收本身。
⚠️ 上面的端口(`8088`)与容器名前缀(`knowledge-governance-p1-`)是**本机运行栈**的实测值;
换环境(测试机 / 生产)要按那边的运行栈改,别无脑照抄。
+15 -6
View File
@@ -4,12 +4,21 @@ COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY internal ./internal
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/api ./cmd/api \
&& CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/worker ./cmd/worker \
&& CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/release-worker ./cmd/release-worker \
&& CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/test-publication-seed ./cmd/test-publication-seed \
&& CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/test-weknora-e2e-report ./cmd/test-weknora-e2e-report \
&& CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/test-weknora-probe ./cmd/test-weknora-probe
# 构建期身份:由调用方 --build-arg 传入。来源是仓库根 CHANGELOG.md 顶部的已发布版本
# (`python3 scripts/version.py`),**不要手填**。不传就落成 dev / unknown —— 那是故意的:
# 一个显眼的 dev 好过一个看起来正常的假版本号(正本 §6.1)。
# ⚠️ TODO:compose.yaml 还没把这三个 args 透传进来(改动会与在审的 PR #2 冲突),
# 所以当前由 compose 构建出的镜像会报 dev;透传后即报真实版本。
ARG VERSION=dev
ARG COMMIT_SHA=unknown
ARG BUILD_TIME=unknown
RUN LDFLAGS="-X knowledge-governance/internal/version.Number=${VERSION} -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 \
&& CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "$LDFLAGS" -o /out/test-publication-seed ./cmd/test-publication-seed \
&& CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "$LDFLAGS" -o /out/test-weknora-e2e-report ./cmd/test-weknora-e2e-report \
&& CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "$LDFLAGS" -o /out/test-weknora-probe ./cmd/test-weknora-probe
FROM alpine:3.22
ENV PYTHONDONTWRITEBYTECODE=1
+30 -3
View File
@@ -1,6 +1,14 @@
GO ?= go
.PHONY: test vet build web-test web-typecheck web-build verify
# 版本号的唯一来源是仓库根的 CHANGELOG.md(正本 §6.1:版本号只有一个来源)。
# 刻意**不用** `git describe --tags`:CI 检出是 `git fetch --depth 50`,取不到 tag,
# 那会让版本号在本地正常、在 CI 上静默退化(见 docs/agents/traps.md trap-5)。
VERSION ?= $(shell python3 scripts/version.py 2>/dev/null || echo dev)
COMMIT_SHA ?= $(shell git rev-parse --short=12 HEAD 2>/dev/null || echo unknown)
BUILD_TIME ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -X knowledge-governance/internal/version.Number=$(VERSION) -X knowledge-governance/internal/version.CommitSHA=$(COMMIT_SHA) -X knowledge-governance/internal/version.BuildTime=$(BUILD_TIME)
.PHONY: test vet build web-test web-typecheck web-build verify version changelog-check version-contract image
test:
$(GO) test ./...
@@ -9,7 +17,7 @@ vet:
$(GO) vet ./...
build:
$(GO) build ./cmd/...
$(GO) build -trimpath -ldflags "$(LDFLAGS)" ./cmd/...
web-test:
npm --prefix web test
@@ -20,4 +28,23 @@ web-typecheck:
web-build:
npm --prefix web run build
verify: test vet build web-test web-typecheck web-build
# 本地门禁:一条命令跑完、**串行**、失败即停(正本 §3.1)。CI 的 build 作业跑同一套。
verify: changelog-check test vet build version-contract web-test web-typecheck web-build
# 打印当前版本号(唯一来源:CHANGELOG.md 顶部那个已发布版本)
version:
@python3 scripts/version.py
# 变更记录结构自检:每个已发布版本都必须有「改了什么」「怎么验收」两节
changelog-check:
@python3 scripts/check_changelog.py
# 版本契约:注入进二进制的号必须等于 CHANGELOG 顶部(不注入时该用例会 SKIP,属正常)
version-contract:
$(GO) test -ldflags "$(LDFLAGS)" -run TestNumberMatchesChangelog -v ./internal/version/...
# 构建镜像。三个 build-arg 必须显式传入 —— 不传就落成 dev/unknown(故意的:
# 一个显眼的 dev 好过一个看起来正常的假版本号)。compose.yaml 的 args 透传见 PR TODO。
image:
docker build --build-arg VERSION=$(VERSION) --build-arg COMMIT_SHA=$(COMMIT_SHA) \
--build-arg BUILD_TIME=$(BUILD_TIME) -t knowledge-governance-p1:$(VERSION)-$(COMMIT_SHA) .
+112
View File
@@ -0,0 +1,112 @@
# 已知陷阱(全文)
> 这份文件是 `AGENTS.md` 第 4 节那张索引的**全文**。拆成两份是有原因的:
> `AGENTS.md` 作为"工作区指令"会被自动加载,而**指令有字节预算** —— 超出的部分会被
> **静默截断**,没有任何报错。索引留在指令文件里(一行一条,一眼扫到),全文放这里。
>
> 纪律:**只追加、不重排、不复用旧号** —— 编号会被代码注释、测试、文档引用(形如"见 trap-3")。
> 新增一条要改两处:这里加全文 + `AGENTS.md` 的索引表加一行。
## trap-1:`.gitignore` 对**已入库**的文件无效
**症状**:规则明明写了 `__pycache__/`,`git ls-files` 里却仍有 8 个
`scripts/__pycache__/*.pyc`。
**根因**:`.gitignore` 只影响"未跟踪"的文件。文件一旦进了索引,规则对它就完全不起作用,
而且**不会有任何提示**。
**判据**:`git ls-files | grep -E '__pycache__/|\.pyc$'` 输出非空即是这条。
**修法**:规则 + `git rm -r --cached <path>`(保留磁盘文件)。
规则齐全不等于没被跟踪 —— 断言要查**两件事**:规则在、且 `git ls-files` 里没有。
**反例**:只加 `.gitignore` 规则就以为修好了。下次 `git add -A` 时它照样进来。
## trap-2:`git rm --cached` 之后跑 `git add -A`,文件会被搬回索引
**症状**:执行了 `git rm --cached node_modules`,`git status` 也显示删除了,
提交后 `git ls-files` 里那个文件**仍在**,净效果为零。
**根因**:`--cached` 只从索引移除、**磁盘文件留着**。如果此刻忽略规则**还没生效**
(本仓当时只写了 `/web/node_modules/`,管不到根级 `node_modules`),
紧接着的 `git add -A` 会把这个"存在且未被忽略"的文件重新加回索引。
**判据**:`git rm --cached X` 后、提交前,先跑 `git check-ignore -v X` ——
必须命中一条规则,否则 `add -A` 会把它捡回来。
**修法**:**先让忽略规则生效,再撤出索引**,并且此后只 `git add <具体文件>`,
不要用 `-A`。
**反例**:把两步写成一条 `git rm --cached X && git add -A && git commit`。
## trap-3:用子串判断代替整行判断
**症状**:脚本判断"规则已存在"时写 `if "/node_modules/" not in 全文`,
而文件里已有 `/web/node_modules/` —— **它包含这个子串**,于是误判为"已存在",
跳过了追加。结果规则根本没加上,而脚本打印"规则已存在"。
**根因**:`.gitignore` 的 `/web/node_modules/` 与 `/node_modules/` 语义完全不同
(前者只管 `web/` 下),但作为字符串是**包含关系**。
**判据**:任何"这条规则在不在"的判断,都要对**行集合**做精确比较
(`{l.strip() for l in lines}`),不要对全文做 `in`。
**反例**:`if "xxx" not in text` 这类判断在多条相似规则并存时必然出错。
## trap-4:Gitea 合并接口成功时返回**空 body**
**症状**:脚本调用合并接口,HTTP 200,但解析 JSON 抛
`json.decoder.JSONDecodeError: Expecting value: line 1 column 1`,
看起来像"合并失败",于是重试 —— 而 PR 其实已经合并了。
**根因**:Gitea 的 `POST /pulls/{n}/merge` 成功时 body 为空(不是 `{"merged":true}`)。
把"HTTP 200 + 空 body"当成失败是客户端的问题,不是服务端的问题。
**判据**:合并后 **不要**信 POST 的返回,改 `GET /pulls/{n}`,
要求 `state=closed` + `merged=true` + `merge_commit_sha` 非空。
**反例**:在合并接口上做重试。**永不重试** —— 会得到"第二次合并失败"的假故障。
## trap-5:CI 检出是 `git fetch --depth 50`,**取不到 tag**
**症状**:想让版本号来自 `git describe --tags`,在 CI 上失败或给出误导结果。
**根因**:工作流里是 `git fetch -q --depth 50 origin "$BR"`,浅克隆不带 tag。
`git describe` 要么报 `No names found`,要么退回到一个和预期无关的短 sha。
**判据**:任何依赖 tag 或完整历史的步骤,必须在 CI 里显式
`git fetch --unshallow` 或 `git fetch --tags`;否则就是这条。
**修法(本仓采用)**:版本号的唯一来源改为仓库里的 `CHANGELOG.md`
(`python3 scripts/version.py`),文件一定在,不依赖 tag。
**反例**:`VERSION=$(git describe --tags --always)` —— 本地看着正常,CI 上静默退化。
## trap-6:act_runner **不会**自动检出代码
**症状**:工作流第一步就报找不到文件 / 空工作区。
**根因**:本仓的 act_runner 不会替作业做 `git clone`,工作区起始为空。
**判据**:工作流里若没有 `git init` + `git remote add` + `git fetch` + `git checkout`,
就是这条。
**修法**:照 `.gitea/workflows/build.yml` 的「检出代码」步写(带 `x-access-token` 的
remote,令牌来自 `secrets.GITHUB_TOKEN`)。
**反例**:以为 runner 会像 GitHub Actions 那样自动 checkout,直接写业务步骤。
## trap-7:`grep -q` 在 `pipefail` 下会被 SIGPIPE 谎报失败
**症状**:`echo "$OUT" | grep -q 'PASS'` 明明匹配上了,整条命令却返回非 0
(141),在 `set -o pipefail` 的作业里表现为"检查失败"。
**根因**:`grep -q` 一命中就退出并关闭管道,上游 `echo` 随即收到 SIGPIPE,
整条管道返回 141。
**判据**:`set -o pipefail` + `... | grep -q ...` 同时出现即是这条。
**修法**:用 `case "$OUT" in *PASS*) ... ;; esac`,或让 `grep` 直接读文件;
非用管道不可时用 `set +e; set +o pipefail` + `${PIPESTATUS[0]}`。
**反例**:`if echo "$x" | grep -q y; then` —— 时红时绿,且方向完全错。
+75
View File
@@ -0,0 +1,75 @@
# 提交规范
正本:**Git / 提交 / CI-CD 规范(Gitea 版)** —— 仓库 `zheromiku/eng-standards-main`,
本机工作副本 `~/eng-standards/`。这份文件是本仓的执行版。
## 格式
```
<类型>(<范围>): 一句话说清"改了什么"
正文:为什么这么改 / 现象 / 根因 / 怎么验的。
```
- **类型**:`feat` 新功能 | `fix` 修缺陷 | `docs` 文档 | `style` 格式(不改逻辑) |
`refactor` 重构 | `perf` 性能 | `test` 测试 | `build` 构建/依赖 | `ci` CI 配置 |
`chore` 杂务 | `revert` 回滚
- **范围**:可选、小写,取自本仓真实模块名(如 `card`、`release`、`monitor`、
`settings`、`source-catalog`、`httpapi`、`deploy`)
- 冒号后**一个空格**;描述 **≥3 字符**;标题 **≤72 字符**;**结尾不加句号**
- 破坏性变更:类型后加 `!`,并在正文写 `BREAKING CHANGE:` 说明
- **一件事一个提交**,且每个提交都要能各自编译/构建通过(否则 `git bisect` 失效)
放行自动生成的提交:`Merge ` / `Revert "` / `fixup! ` / `squash! ` / `amend! ` 开头。
## 正文写"为什么",不写"是什么"
diff 已经写了"是什么"。正文回答:**当时为什么这么改、试过什么、怎么确认它对了**。
四种要素(可直接照抄形状):
| 要素 | 作用 |
|---|---|
| 现象(用户原话 / 报错原文) | 让后来的人对上号:我遇到的是不是同一件事 |
| 根因(具体到文件·行·机制) | 正文核心;"没渲染"和"按纯文本渲染"是两回事 |
| 取舍(为什么不用那个更自然的写法) | 防止下一个人"顺手现代化"改回去 |
| 证据(跑了什么、量到了什么) | 把结论钉在可复现的命令上 |
## 两条闸门(都在这条链上)
1. **本机 hook** `~/.githooks/commit-msg`(由 `core.hooksPath` 全局生效)—— 快速反馈
2. **CI 权威闸门** `.gitea/scripts/ci_gate.py` —— 在**编译之前**执行,非法提交**不编译**
⚠️ `git commit --no-verify` 只骗得过第一条。CI 会校验本次推送的**每一条**提交,
有一条不合规整条流水线红。
## 写法禁忌
**不要在提交信息里用反引号与 `$()`**:shell 会先做命令替换,重则**执行**了里面的东西,
而且**退出码仍是 0、没有任何报错**。一律用带引号的 heredoc:
```bash
git commit -F - <<'EOF'
fix(parser): 修复表格解析越界导致的 500
根因:……
EOF
```
`<<'EOF'` 的单引号是"里面的字符都是字面量"的唯一保证。
## 分支与推送
`main` **已启用分支保护**:禁止直接推送,一切改动走短命分支 + PR。
```bash
git switch -c <type>/<slug> # 短命分支,合完即删
# … 改动 …
make verify # 本仓门禁;跳过项会显式警告"不算全绿"
git commit -F - <<'EOF'
<类型>(<范围>): 标题
EOF
git push -u origin HEAD
```
单条规则与 CI 闸门完全一致的判据见 `.gitea/scripts/ci_gate.py`(两处必须同步改,
不一致的表现是"本地绿、CI 红"或反之)。
+28
View File
@@ -0,0 +1,28 @@
// Package version 承载"这个二进制是什么"的构建期身份。
//
// 三个值**全部由构建期注入**(正本 §6.1:版本号只有一个来源,且要能从产物读回来):
//
// go build -trimpath \
// -ldflags "-X knowledge-governance/internal/version.Number=$(python3 scripts/version.py) \
// -X knowledge-governance/internal/version.CommitSHA=$(git rev-parse --short=12 HEAD) \
// -X knowledge-governance/internal/version.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
// ./cmd/api
//
// 三条纪律:
//
// 1. **不要**把它们改成编译期常量,也**不要**另建仓库根的 VERSION 文件 ——
// 版本号的唯一来源是 CHANGELOG.md,由 scripts/version.py 解析。
// 放第二份副本的结果是发版要改两处,忘一处就漂移。
// 2. **不要**给这些变量设"看起来正常的默认值"。未注入时保持下面的零值,
// 好让"忘了注入"表现成一个显眼的 dev,而不是一个假的正经版本号。
// 3. `Number` 的正确性由 internal/version 的契约测试兜底:注入进去的号
// 必须等于 CHANGELOG.md 顶部的已发布版本(CI 会带 -ldflags 跑它,
// 并额外断言"没有 SKIP" —— 被跳过的断言等于没有断言)。
package version
// Number / CommitSHA / BuildTime 由 -ldflags -X 注入;未注入时保持下面的值。
var (
Number = "dev"
CommitSHA = "unknown"
BuildTime = "unknown"
)
+40
View File
@@ -0,0 +1,40 @@
package version
import (
"os"
"path/filepath"
"regexp"
"testing"
)
// 版本契约:注入进二进制的号,必须等于仓库根 CHANGELOG.md 顶部的已发布版本。
//
// 为什么要有这条断言:CHANGELOG.md 是版本号的**唯一来源**,而注入是构建期动作。
// 两者只要没人对账,就会出现"改了 CHANGELOG 没重新构建"(产物报旧号)或
// "构建时手填了一个号"(产物报的号 CHANGELOG 里根本没有)—— 两种都让
// "线上跑的是哪个提交/版本"这个问题失去答案,而症状是静默的。
//
// 未注入时(普通 `go test ./...`)本用例 Skip:本地跑全量测试不必先构建。
// 但 CI 必须带 -ldflags 跑它,**并断言没有 SKIP**(.gitea/workflows/build.yml
// 的「版本与变更记录契约」步;被跳过的断言等于没有断言)。
func TestNumberMatchesChangelog(t *testing.T) {
if Number == "dev" {
t.Skip("版本号未注入(没有 -ldflags);CI 会注入后跑这条断言")
}
path := filepath.Join("..", "..", "CHANGELOG.md")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读不到 %s:%v(版本号的唯一来源就在这里)", path, err)
}
m := regexp.MustCompile(`(?m)^##\s*\[(\d+\.\d+\.\d+)\]`).FindSubmatch(raw)
if m == nil {
t.Fatalf("%s 里没有已发布版本条目(形如 `## [0.1.0] - 2026-01-01`);"+
"没有条目就没有版本号,不该回落到一个假版本", path)
}
want := string(m[1])
if Number != want {
t.Fatalf("注入的版本 %q != CHANGELOG 顶部 %q。"+
"常见原因:改了 CHANGELOG 却没重新构建(产物还报旧号),"+
"或者构建时手填了一个 CHANGELOG 里没有的号", Number, want)
}
}
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""CHANGELOG.md 结构自检 —— 把"版本变更要写清改了什么、怎么验收"变成退出码。
判据(每条都能被 --self-test 证明会红):
1. 至少有一个已发布版本条目;
2. 每个已发布版本都带日期(`## [x.y.z] - YYYY-MM-DD`);`[Unreleased]` 段免检日期;
3. 每个已发布版本块里**必须有** `### 改了什么` 与 `### 怎么验收` 两节;
4. 版本号不重复,且按出现顺序**严格递减**(最新在最上面);
5. 已发布版本块本身不为空(两节下面得有内容,不能只留个标题)。
用法:
python3 scripts/check-changelog.py # 检查仓库根的 CHANGELOG.md
python3 scripts/check-changelog.py <path> # 检查指定文件
python3 scripts/check-changelog.py --self-test # 反向自检:造正/反夹具断言会红会绿
为什么要有 --self-test:一个永远返回 0 的校验器比没有校验更糟 —— 它给人一种
已经检查过的错觉(正本 §0-2)。
"""
from __future__ import annotations
import re
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
HEADING = re.compile(r"^##\s*\[([^\]]+)\](\s*-\s*(\d{4}-\d{2}-\d{2}))?\s*$", re.MULTILINE)
SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
SEC_CHANGE = "### 改了什么"
SEC_ACCEPT = "### 怎么验收"
def _blocks(text: str):
"""把文件切成 [(heading, version, date, body), ...],保持出现顺序。"""
out = []
matches = list(HEADING.finditer(text))
for i, m in enumerate(matches):
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
out.append((m.group(0), m.group(1), m.group(3), text[m.end():end]))
return out
def check(path: Path | None = None) -> list[str]:
path = path or (ROOT / "CHANGELOG.md")
if not path.is_file():
return [f"找不到 {path}"]
blocks = _blocks(path.read_text(encoding="utf-8"))
released = [b for b in blocks if SEMVER.match(b[1])]
if not released:
return ["没有已发布版本条目(形如 `## [0.1.0] - 2026-10-10`)"]
problems: list[str] = []
seen: list[str] = []
for heading, ver, date, body in released:
seen.append(ver)
if not date:
problems.append(f"{heading} 缺少日期(要写成 `## [{ver}] - YYYY-MM-DD`)")
for sec in (SEC_CHANGE, SEC_ACCEPT):
if sec not in body:
problems.append(f"[{ver}] 缺少小节 `{sec}` —— 版本条目必须说明改了什么、怎么验收")
# 两节下面无内容:只剩标题或空行
for sec in (SEC_CHANGE, SEC_ACCEPT):
if sec in body:
tail = body.split(sec, 1)[1]
tail = re.split(r"^###\s", tail, maxsplit=1, flags=re.MULTILINE)[0]
if not [l for l in tail.splitlines() if l.strip() and not l.strip().startswith("```")][:1]:
problems.append(f"[{ver}] 的 `{sec}` 下面是空的")
dupes = {v for v in seen if seen.count(v) > 1}
if dupes:
problems.append(f"版本号重复:{sorted(dupes)}")
nums = [tuple(int(x) for x in v.split(".")) for v in seen]
if nums != sorted(nums, reverse=True):
problems.append(f"版本号没有按出现顺序严格递减(最新应在最上面):{seen}")
return problems
GOOD = """# 变更记录
## [Unreleased]
### 改了什么
- 还没发布的东西
## [0.2.0] - 2026-02-01
### 改了什么
- 加了 X
### 怎么验收
1. 跑 `curl /health` 期望 200
## [0.1.0] - 2026-01-01
### 改了什么
- 初版
### 怎么验收
1. 起容器,打开首页
"""
BAD = """# 变更记录
## [0.1.0]
### 改了什么
- 没有日期、没有怎么验收、而且版本顺序还倒着
## [0.2.0] - 2026-02-01
### 改了什么
- 上面那个比这个旧,顺序错了
"""
def self_test() -> int:
problems: list[str] = []
with tempfile.TemporaryDirectory() as d:
g = Path(d) / "good.md"
b = Path(d) / "bad.md"
g.write_text(GOOD, encoding="utf-8")
b.write_text(BAD, encoding="utf-8")
gk = check(g)
bk = check(b)
if gk:
problems.append(f"合规夹具竟然报红:{gk}")
want = ["缺少日期", f"缺少小节 `{SEC_ACCEPT}`", "严格递减"]
joined = " / ".join(bk)
for w in want:
if w not in joined:
problems.append(f"违规夹具没有报出 `{w}` —— 这条判据是死的")
if not bk:
problems.append("违规夹具完全没报红")
if problems:
print("❌ 反向自检失败:", file=sys.stderr)
for p in problems:
print(f" · {p}", file=sys.stderr)
return 1
print("✅ 反向自检通过:合规夹具全绿;违规夹具按预期报出日期/验收节/顺序三类问题")
return 0
def main(argv: list[str]) -> int:
if "--self-test" in argv:
return self_test()
target = next((a for a in argv if not a.startswith("-")), None)
problems = check(Path(target) if target else None)
if problems:
print("❌ CHANGELOG.md 结构不合规:", file=sys.stderr)
for p in problems:
print(f" · {p}", file=sys.stderr)
print("\n每个已发布版本都要写成:\n"
" ## [x.y.z] - YYYY-MM-DD\n\n ### 改了什么\n - …\n\n ### 怎么验收\n 1. 命令 → 期望现象\n",
file=sys.stderr)
return 1
print("✅ CHANGELOG.md 结构合规")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""打印版本号 —— 唯一来源是仓库根的 CHANGELOG.md。
为什么是它而不是 git tag:CI 检出是 `git fetch --depth 50`,**取不到 tag**,
`git describe --tags` 会失败或者给出误导性的结果。仓库里的文件则一定在。
为什么不是另放一个 `VERSION` 文件:那是第二份副本,发版要改两处,忘一处就漂移
(正本 §3.1)。CHANGELOG.md 里本来就必须写版本号,让它当源头,副本数为一。
用法:
python3 scripts/version.py # 打印版本号;解析不到就以非 0 退出
python3 scripts/version.py --check # 同时跑结构自检(等价 check-changelog.py)
退出码非 0 表示"CHANGELOG 里没有已发布版本条目" —— 构建应当据此失败,
而不是回落成一个看起来正常的假版本号。
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CHANGELOG = ROOT / "CHANGELOG.md"
# 已发布版本标题:`## [0.1.0] - 2026-10-10` / `## [0.1.0]`;`## [Unreleased]` 不算
RELEASE_HEADING = re.compile(r"^##\s*\[(\d+\.\d+\.\d+)\]", re.MULTILINE)
def current_version(path: Path = CHANGELOG) -> str | None:
"""取最上面那个已发布版本号;没有就返回 None。"""
if not path.is_file():
return None
m = RELEASE_HEADING.search(path.read_text(encoding="utf-8"))
return m.group(1) if m else None
def main(argv: list[str]) -> int:
v = current_version()
if v is None:
print(
"❌ CHANGELOG.md 里找不到已发布版本条目(形如 `## [0.1.0] - 2026-10-10`)。\n"
" 版本号解析不到 ⇒ 构建应当失败,而不是回落成假版本号。",
file=sys.stderr,
)
return 1
if "--check" in argv:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from check_changelog import check # type: ignore # noqa: PLC0415
problems = check()
if problems:
for p in problems:
print(f"❌ {p}", file=sys.stderr)
return 1
print(v)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))