diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 1292cc5..77efea3 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -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: | diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b14b2d4 --- /dev/null +++ b/CHANGELOG.md @@ -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-`)是**本机运行栈**的实测值; +换环境(测试机 / 生产)要按那边的运行栈改,别无脑照抄。 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 6019fdb..86f63c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Makefile b/Makefile index f63af64..2e906c0 100644 --- a/Makefile +++ b/Makefile @@ -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) . diff --git a/docs/agents/traps.md b/docs/agents/traps.md new file mode 100644 index 0000000..40e0dcc --- /dev/null +++ b/docs/agents/traps.md @@ -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 `(保留磁盘文件)。 +规则齐全不等于没被跟踪 —— 断言要查**两件事**:规则在、且 `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` —— 时红时绿,且方向完全错。 \ No newline at end of file diff --git a/docs/提交规范.md b/docs/提交规范.md new file mode 100644 index 0000000..c40cf8c --- /dev/null +++ b/docs/提交规范.md @@ -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 / # 短命分支,合完即删 +# … 改动 … +make verify # 本仓门禁;跳过项会显式警告"不算全绿" +git commit -F - <<'EOF' +<类型>(<范围>): 标题 +EOF +git push -u origin HEAD +``` + +单条规则与 CI 闸门完全一致的判据见 `.gitea/scripts/ci_gate.py`(两处必须同步改, +不一致的表现是"本地绿、CI 红"或反之)。 \ No newline at end of file diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..df56e81 --- /dev/null +++ b/internal/version/version.go @@ -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" +) \ No newline at end of file diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..f9a602e --- /dev/null +++ b/internal/version/version_test.go @@ -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) + } +} \ No newline at end of file diff --git a/scripts/check_changelog.py b/scripts/check_changelog.py new file mode 100755 index 0000000..d3b357d --- /dev/null +++ b/scripts/check_changelog.py @@ -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 # 检查指定文件 + 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:])) \ No newline at end of file diff --git a/scripts/version.py b/scripts/version.py new file mode 100755 index 0000000..0567762 --- /dev/null +++ b/scripts/version.py @@ -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:])) \ No newline at end of file