背景:本仓此前没有任何版本/构建元信息 —— 没有 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:
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) .
|
||||
|
||||
@@ -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` —— 时红时绿,且方向完全错。
|
||||
@@ -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 红"或反之)。
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Executable
+168
@@ -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:]))
|
||||
Executable
+61
@@ -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:]))
|
||||
Reference in New Issue
Block a user