feat(version): 变更记录改固定四类,破坏性变化带发布文档链接
按项目方给的规则重做 CHANGELOG 与检查器(本轮不推、不打 tag、不部署): - 条目形状:四个自定义必填节(改了什么/兼容性与迁移影响/怎么验收/已知问题) → 固定四类 Added/Changed/Fixed/Security;分类可省略,**不许空栏目**;每个版本块 至少一个非空分类;`[Unreleased]` 段不得写日期。 - 破坏性变化:固定写在 Changed 里、以 `**破坏性**` 开头,**必须链接发布文档**; 仓内 `docs/….md` 链接**对账**(不存在 ⇒ 红)。写在别的分类里也判红。 - 结构判据补强:非法日期、重复版本、顺序必须严格递减、前导零;围栏代码块里的 `##`/`###` 不算标题;**块正文以任意二级标题为界**(「## 能力基线(未发布)」这类 不带方括号的标题原先不是边界,会把后文算成上一节的内容 ⇒ 空栏目被兜成"有内容", 夹具真的抓到了这个假绿)。 - tag 一致性:远端专属清单 `.ci-remote-tags` → `ls-remote` → 本地 refs 三级来源; **没核对远端就不许说校验成功**(CI/预检用 `--require-remote`,拿不到清单即红), 非正式 tag(如本地 `x`)不当作发布版本、不判红,没有 tag 不判红。 - `0.1.0` 定为**未发布的能力基线**(远端无 tag、无 Release):改成不带方括号的 「## 能力基线(未发布)」,不补造 tag/发布日期/发布事实。 - 过程记录迁出:审查轮次与命令输出 → `docs/archive/CI_接入留档.md`(原文保留); 未合并事项 → `docs/BACKLOG.md`;补 `docs/RELEASE_RUNBOOK.md`(破坏性变化的链接目标, 同时修掉 CHANGELOG 里指向不存在文档的悬空引用)。 - 发版预检:无版本基线时把「首个正式版本号由人指定」列成阻塞项(不再要求先有基线才能跑); 打印破坏性变化条数;远端 tag 查不到时明说未核对并阻塞;不再硬读已删除的『已知问题』节。 - traps trap-5 口径修正:不是"不用 tag",而是"构建时不要用 git describe 取版本号"。 验证:check_changelog --self-test 全过;反仓夹具 80/0(含五条真改文件的结构负例、 六条只在临时仓库里造 tag 的场景);make verify 15/15 success。
This commit is contained in:
+34
-178
@@ -1,199 +1,55 @@
|
||||
# 变更记录
|
||||
# Changelog
|
||||
|
||||
> **这个文件就是版本号的唯一来源。**
|
||||
> 正本 §3.1 要求"版本号只有一个来源",所以这里**不放** `VERSION` 文件,
|
||||
> 也**不靠 git tag**(CI 是 `git fetch --depth 50`,取不到 tag ⇒ `git describe` 会失败)。
|
||||
> **版本号的唯一来源就是本文件顶部那条已发布版本。** 不放 `VERSION` 文件、不把版本写进源码常量。
|
||||
>
|
||||
> 用法与断言(都可复现,别手抄版本号):
|
||||
> **正式版本**用**注释 tag** `vX.Y.Z`(由人批准后创建):CI 只**核验**「tag 的版本 == 它指到那个提交里的
|
||||
> 版本记录」,**不自动升版本、不打 tag、不发 Release**。日常改动写进 `[Unreleased]`;
|
||||
> **不要求每个提交都改本文件** —— 有使用者可见的变化才记,其余走提交信息与档案。
|
||||
>
|
||||
> ```bash
|
||||
> python3 scripts/version.py # 打印顶部那个「已发布」版本号
|
||||
> python3 scripts/check-changelog.py # 结构自检:每个版本都要有「改了什么」「怎么验收」
|
||||
> python3 scripts/version.py --identity # 构建身份:release_version / build_kind / commit_sha / display_version / built_at
|
||||
> python3 scripts/check_changelog.py --tags # 结构 + tag 一致性(无正式 tag 不判红)
|
||||
> ```
|
||||
>
|
||||
> 构建时用 `-ldflags "-X knowledge-governance/internal/version.Number=<号>"` 注入,
|
||||
> CI 断言"二进制报出的版本 == 本文件顶部"。**没有条目 ⇒ 解析失败 ⇒ 构建红**,
|
||||
> 所以"忘了记变更"这件事过不去。
|
||||
> 小节**只认四类**(分类可省略,但**不要空栏目**):`### Added` / `### Changed` / `### Fixed` / `### Security`。
|
||||
> **破坏性变化**写在 `### Changed` 里、以 `**破坏性**` 开头,并链接发布文档(见 `docs/RELEASE_RUNBOOK.md`)。
|
||||
>
|
||||
> **每个已发布版本必须有两节**:`### 改了什么` 与 `### 怎么验收`。
|
||||
> 缺任一节 `check-changelog.py` 报红(CI 会跑它)。
|
||||
>
|
||||
> 纪律:最新版本在最上面;版本号**只增不减**(回退走部署层,不改这个文件);
|
||||
> 日期写 `YYYY-MM-DD`;`## [Unreleased]` 段可以只有「改了什么」。
|
||||
> 过程记录(审查轮次、命令输出、逐条修正明细)不入本文件:归档在 `docs/archive/`,
|
||||
> 未合并事项在 `docs/BACKLOG.md`。
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 改了什么
|
||||
### Added
|
||||
|
||||
- 待合并:PR #2 —— 批量发布 / 建批即锁定 / 需下架逐条移除 / 数据源勾选改造(23 个提交,80 个文件)
|
||||
- 待合并:仓库卫生 —— 撤出误入库的 `node_modules/.vite/.../results.json` 与 8 个历史 `scripts/__pycache__/*.pyc`
|
||||
- 唯一权威门禁 `make verify`:一条命令跑完必需检查并产出结构化报告,本地与 CI 跑**同一条命令**。
|
||||
- 静态预览发布前先做同一份完整核对:报告有任何缺项就**一个 publish 请求都不发**。
|
||||
|
||||
### 改了什么(CI 接入这一批)
|
||||
### Changed
|
||||
|
||||
**唯一权威门禁**:`make verify` = `python3 scripts/ci_checks.py` —— 一条命令跑完 14 项必需检查、
|
||||
串行、失败即停,产出结构化报告 `ci-report.json`。本地与 CI 跑的是**同一条命令**(不再各写一套)。
|
||||
- 依赖升级:`vite` 7.1.3 → 7.3.7、`vitest` 3.2.4 → 5.0.3(`npm audit` 全量 0 命中)。
|
||||
- 前端存量格式债记入基线 `web/.prettier-baseline.json`(79 个文件,**只能减**,2026-12-31 到期)。
|
||||
|
||||
- 新增 14 项检查:`identity`(检出 == 事件完整 SHA)、`commits`、`deps-install`(按锁安装)、
|
||||
`format`(Go gofmt + 前端 prettier)、`static-go`(go vet)、`static-web`(tsc)、
|
||||
`build-go`(带版本注入)、`build-web`、`tests-go`、`tests-web`、`contracts`(版本契约**必须真 PASS**)、
|
||||
`security-secrets`、`security-deps`(govulncheck + npm audit 分级 + 例外)、`manifests-locked`。
|
||||
- 汇总按**清单**逐项核对:失败即停后剩下的记 `not_run`,**未运行/被跳过一律不算通过**。
|
||||
- `.gitea/workflows/build.yml` 改成**单作业**(避免跨作业 `/tmp` 状态传递的假契约):检出固定事件完整 SHA →
|
||||
环境与工具链 → `make verify` → 静态预览(**只在全部必需检查通过后**发布,且断言包里有 `index.html`、
|
||||
报告里每个地址都 200)→ 上报 ci-bridge(**通知失败不改变质量结论**)。
|
||||
- 本 workflow **只做 CI**:删掉了 `deploy:test` 占位作业(原来它会打印 TODO 然后返回成功 = 假报成功)。
|
||||
- 新增脚本:`scripts/ci_checks.py`(门禁 + 汇总)、`scripts/check_format.py`(格式,含只能减的基线)、
|
||||
`scripts/check_secrets.py`(密钥扫描,带双向自检)、`scripts/check_deps_vuln.py`(依赖漏洞分级)。
|
||||
四个脚本都带 `--self-test`(反向自检:故意弄坏必须红)。
|
||||
- `security/dependency-exceptions.json`:依赖漏洞例外登记(缺字段/过期/批准人是占位值 ⇒ 例外无效)。
|
||||
- 依赖:`vite` 7.1.3 → **7.3.7**、`vitest` 3.2.4 → **5.0.3**(清掉 npm audit 的 2 critical + 2 high + 1 moderate,
|
||||
现在全量 0 命中);新增 `prettier` 3.9.10;`web/package.json` 加 `format` / `format:check`。
|
||||
- 格式债:Go 侧 6 个文件 `gofmt -w`(存量清干净,Go 侧一条都不欠);前端 79 个文件的存量格式债
|
||||
记入 `web/.prettier-baseline.json`(**列明 + 只能减 + 2026-12-31 到期**,不新增欠账即绿)。
|
||||
- `AGENTS.md` 的现状表**已过期**(写着"main 已启用分支保护",实际 `GET /branch_protections` = `[]`)——
|
||||
修正稿已拟好,但该文件受写入保护,**待人工批准后应用**(见交付说明)。
|
||||
### Fixed
|
||||
|
||||
### 怎么验收
|
||||
- 新分支推送时提交范围恒为空(基准改为远端默认分支)。
|
||||
- 空工作区下 CI 起不来(工作流首步改为最小 bootstrap)。
|
||||
- 事件负载缺 `total_commits` 时提交范围解析抛未收敛异常。
|
||||
|
||||
```bash
|
||||
# 1) 门禁自身可跑、且能证明自己会红(反向自检)
|
||||
python3 scripts/ci_checks.py --self-test # 期望:✅ 汇总逻辑 7 个分支符合预期
|
||||
python3 scripts/check_format.py --self-test # 期望:✅ 6 个判定分支
|
||||
python3 scripts/check_secrets.py --self-test # 期望:✅ 7 条合成密钥全部命中,良性对照零误报
|
||||
python3 scripts/check_deps_vuln.py --self-test # 期望:✅ 9 个判据 + JSON 流解析器
|
||||
### Security
|
||||
|
||||
# 2) 一条命令跑完全部门禁(约 1 分钟;会跑 npm ci,请留时间)
|
||||
make verify
|
||||
# 期望:末尾"总体:success(档案 local)",identity=skipped(本地无事件负载,已在清单里声明)
|
||||
- 依赖漏洞例外登记 `security/dependency-exceptions.json`:缺字段 / 过期 / 批准人是占位值 ⇒ 例外无效。
|
||||
|
||||
# 3) 清单与真实结果对账
|
||||
make list # 期望:14 项,identity 标 ci
|
||||
python3 -c "import json;d=json.load(open('ci-report.json'));print(d['overall_status'],len(d['checks']))"
|
||||
# 期望:success 14
|
||||
```
|
||||
## 能力基线(未发布):2026-10-10 的上线能力
|
||||
|
||||
### 改了什么(第二轮审查修正)
|
||||
> 这一条**不是正式发布版本**:当时本仓既没有版本号、也没有变更记录,**没有 tag、没有 Release**。
|
||||
> 它只是把已上线能力补记在此,供此后发版对照;版本号体系从**下一个正式版本**开始(首个正式版本号由人指定)。
|
||||
|
||||
审查(GPT,第二轮)在**通用模板**与**预览发布顺序**上又找出 6 处,逐条修完并补了负例:
|
||||
- **来源与目录**:列出可选的钉钉知识库 → 勾选 → 变成来源;快照与变更集。
|
||||
- **发布管理**:计划 → 建批 → 锁定 → 授权发布(落 WeKnora 生产)。
|
||||
- **监控 + 需下架车道**:测试库知识逐条下架。
|
||||
- **钉钉侧**:知识库发现 / 注册、取件走 dws;登录与系统设置。
|
||||
- **运行形态**:`knowledge-governance-p1-*` —— api / web / worker / release-worker / dingtalk-login /
|
||||
postgres 共 6 个容器;web 发布在 `:8088`。
|
||||
- **验收口径**:`docs/USER_FLOWS_ACCEPTANCE.md`(C1~C9)与 `docs/OPERATIONS_RUNBOOK.md`。
|
||||
|
||||
- **P0 通用模板的 Python 检查是空跑** → `templates/ci-gate.py` 的 `deps-install/format/static/build/tests`
|
||||
对 Python 现在**真的执行**(`uv sync` / `uv run ruff` / `compileall` / `uv run pytest`);
|
||||
工具缺失 ⇒ 明确失败(`工具缺失:uv(…不空跑)`),不再"没写检查所以返回成功"。
|
||||
前端测试**进程退出码也算判据**:报告写 pass 但进程退出 1 ⇒ 红(实测过)。
|
||||
前端框架不再写死 vitest:读 `scripts/test-scope.json` 的声明,认不出来 ⇒ 明确失败。
|
||||
通用清单补了 `security`(密钥扫描 + 依赖漏洞),工具缺失/输出解析不了 ⇒ 红。
|
||||
- **P0 预览发布早于报告完整验证** → 预览逻辑移进 `.gitea/scripts/ci_preview.py`:**先**调
|
||||
`ci_report.verify()`(与上报同一份核对逻辑),有问题就**一个 publish 请求都不发**;
|
||||
通过后才打包(断言包内有 `index.html`、无 `.` 根成员)→ POST → 报告里每个地址都探测 200。
|
||||
`scripts/ci_preview_negative_test.py` 用**假 bridge 记录请求**证明:10 个坏报告分支 publish 次数都是 0,
|
||||
合规报告正好 1 次。
|
||||
- **P1 提交范围可能漏查** → `.gitea/scripts/ci_gate.py` 以**准确 Git 范围**为准(push 事件用
|
||||
`before..after`,新分支用 `after --not --remotes`),事件声明的条数与列表/Git 范围不一致 ⇒ 拒绝;
|
||||
列表被截断但 Git 范围可用 ⇒ 以 Git 为准并写明;空范围、浅克隆取不到 `before`、列表提交不在检出里
|
||||
⇒ 全部拒绝;手工运行只核 HEAD 但**写明**这一点。`--self-test` 覆盖 11 个分支。
|
||||
`.gitea/scripts/ci_checkout.sh` 顺带把事件 `before` 也取下来(否则范围算不出来)。
|
||||
- **P1 合并必需检查可以为空** → `adopt-existing.py` 的必需 context 只能来自**明确策略**
|
||||
(`--require-context` 或仓库里的 `.gitea/required-contexts.txt`);两个都没有 / 文件是空的 ⇒ 拒绝合并,
|
||||
**不再用"体检观察到的状态"顶替**。同时核对 PR 目标分支 == 默认分支、`state=open`、head 仓库就是本仓。
|
||||
`--self-test-chain`(假 Gitea + 真实 `merge_pr()`)证明 13 个分支里只有"策略齐 + 必需 context 全 success
|
||||
+ 完整获批 SHA + base/state/来源都对"才发出 merge 请求。
|
||||
- **P1 协议被静默降级** → 检出逻辑移进 `.gitea/scripts/ci_checkout.sh`,remote 直接用
|
||||
`GITHUB_SERVER_URL` 原样拼接并**断言没有降级**(https 不许变 http)、URL 里不许带凭据、
|
||||
检出必须等于事件完整 SHA。`--print-remote` 可本地验证协议保留。
|
||||
|
||||
仍未做/未验证(见文末"未部署"):本地检查通过 ≠ 远端 CI 已跑通;两仓**均未推送**。
|
||||
|
||||
### 改了什么(第三轮审查修正:启动与范围)
|
||||
|
||||
第三轮审查(GPT)实际复现了两个会**卡住真实 CI** 的问题,逐条修完并补了完整调用链夹具:
|
||||
|
||||
- **P0 空工作区起不来** → 旧写法把"检出"写成仓内脚本(`bash .gitea/scripts/ci_checkout.sh`),
|
||||
而这个 Runner 不自动检出 ⇒ 工作区为空时脚本不存在,实测 **rc=127**。现在 workflow 第一步是
|
||||
**最小 bootstrap**(只用 runner 的环境变量:校验协议/凭据 → `git init` + `remote add` →
|
||||
取**事件完整 SHA** → checkout → 断言 HEAD==事件 SHA),检出完成、脚本就位后才调用
|
||||
`ci_checkout.sh --post-checkout` 做再断言与补取(默认分支引用、事件 `before`)。
|
||||
顺带修掉"按任意 SHA `fetch`"——服务端默认不允许(`服务器不允许请求未公开的对象`),改为
|
||||
"取分支历史 + `--deepen` 再核事件 SHA"。
|
||||
- **P1 新分支范围恒为空** → `after --not --remotes` 用的是**刚 fetch 的同名远端分支**,
|
||||
它等于 `after`,于是把自身排除、范围恒为 0 条。现在新分支的基准是**远端默认分支**
|
||||
(`repository.default_branch`):范围 = `refs/remotes/origin/<default>..<after>`;
|
||||
取不到基准 ⇒ 明确失败,绝不用整部历史或空集糊过去。
|
||||
- **P1 通用模板 Python 未锁定** → `templates/ci-gate.py` 不再有"缺锁就 `uv sync`"和
|
||||
`uv pip install --system`:必须有 `uv.lock` 才装(`uv sync --frozen`),所有检查走
|
||||
`uv run --frozen`(项目内 `.venv` 隔离),并断言 `sys.prefix != sys.base_prefix`;
|
||||
只有 `requirements.txt` ⇒ 明确失败并要求先生成锁文件。
|
||||
- **新增完整调用链夹具** `scripts/ci_chain_fixture.py`:本地 HTTP git 服务(真 `git http-backend`)+
|
||||
**把 workflow 里的 bootstrap `run:` 块原样抽出来在空目录执行** → 再调用仓内脚本 →
|
||||
再跑提交范围闸门。正例(检出到事件 SHA、协议保留、合规新分支通过)与负例
|
||||
(旧写法 127、取不到 SHA 失败、含违规提交的新分支判红且**红在那条提交上**)共 12 项断言。
|
||||
|
||||
- **第一次真实 Runner 首跑又抓到一处**:新分支推送时 Gitea 的 `total_commits=0`、`commits` 列表被截断(5 条),
|
||||
而 Git 范围是 12 条 —— 原先"声明条数 ≠ Git 范围就拒绝"的严判让门禁在真实 CI 上第一次就红了,且与代码质量无关。
|
||||
改成:**Git 范围是唯一权威**,`total_commits > 0` 才交叉核对,`<=0`/缺失视为"未声明"并把
|
||||
"负载条数不可信、列表被截断"写进来源说明(`--self-test` 增加对应分支,见 traps 22)。
|
||||
|
||||
|
||||
|
||||
## [0.1.0] - 2026-10-10
|
||||
|
||||
### 改了什么
|
||||
|
||||
**初始基线(补记)**:本仓此前既没有版本号、也没有变更记录,本次首次建立。
|
||||
下面按**已上线能力**归纳,不逐条追溯历史提交。
|
||||
|
||||
- **来源与目录**:列出可选的钉钉知识库 → 勾选 → 变成来源;快照与变更集
|
||||
- **发布管理**:计划 → 建批 → 锁定 → 授权发布(落 WeKnora 生产)
|
||||
- **监控 + 需下架车道**:测试库知识逐条下架
|
||||
- **钉钉侧**:知识库发现 / 注册、取件走 dws;登录与系统设置
|
||||
- **运行形态**:`knowledge-governance-p1-*` —— api / web / worker / release-worker /
|
||||
dingtalk-login / postgres 共 6 个容器;web 发布在 `:8088`
|
||||
|
||||
### 兼容性与迁移影响
|
||||
|
||||
- **首次发布,没有上一个版本 ⇒ 无升级迁移**:部署形态是全新一套容器
|
||||
(`knowledge-governance-p1-*`:api / web / worker / release-worker / dingtalk-login / postgres),
|
||||
不回改任何既有服务。
|
||||
- **数据层**:用既有阿里云 RDS(PostgreSQL 17.9);本次**没有** schema 迁移,
|
||||
后续版本一旦改 schema,必须在本节写清迁移步骤与回退办法。
|
||||
- **接口**:`/api/*` 与页面同批上线,没有需要兼容的旧调用方。
|
||||
|
||||
### 怎么验收
|
||||
|
||||
**冒烟(约 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` —— 现行用户操作流程与运维步骤
|
||||
|
||||
### 已知问题
|
||||
|
||||
- **前端格式债**:prettier 有 79 个存量文件在基线 `web/.prettier-baseline.json` 里(到期 2026-12-31),
|
||||
基线内的文件不参与格式检查;基线只能减、不能增。
|
||||
- **集成测试需要 `TEST_DATABASE_URL`**:CI 里按 `scripts/test-scope.json` 的声明跳过(已声明为可选),
|
||||
不是漏跑;本地要跑全量就带上这个变量。
|
||||
- **通知有会话窗口限制**:钉钉/微信推送在窗口外会 `ret=-2`,需要本人给 bot 发消息后由 bridge 队列补投。
|
||||
- **本仓 CI 只做 CI**:不含 CD;部署与正式发版仍按各自 runbook 人工执行(见 `docs/RELEASE_RUNBOOK.md`)。
|
||||
|
||||
⚠️ **冒烟通过 ≠ 验收通过**。面向上线的那一次验收必须走完 C1~C9,由人确认并留下记录;
|
||||
"服务健康"只是可继续验收的前提,不是验收本身。
|
||||
|
||||
⚠️ 上面的端口(`8088`)与容器名前缀(`knowledge-governance-p1-`)是**本机运行栈**的实测值;
|
||||
换环境(测试机 / 生产)要按那边的运行栈改,别无脑照抄。
|
||||
⚠️ 上面的端口与容器名前缀是**本机运行栈**的实测值;换环境要按那边的运行栈改,别无脑照抄。
|
||||
@@ -0,0 +1,21 @@
|
||||
# 待办(未合并事项)
|
||||
|
||||
> **这里的东西都不是已完成变更**,不写进 `CHANGELOG.md`。做完并验收后,把使用者可见的部分写成
|
||||
> `[Unreleased]` 条目,再从这里划掉。
|
||||
>
|
||||
> 建立于 2026-10-13(从 `CHANGELOG.md` 的 `[Unreleased]` 段迁出)。
|
||||
|
||||
## 待合并 / 待评审
|
||||
|
||||
| # | 事项 | 现状 | 完成判据 |
|
||||
|---|---|---|---|
|
||||
| 1 | PR #2:批量发布 / 建批即锁定 / 需下架逐条移除 / 数据源勾选改造(23 个提交,80 个文件) | **未合并** | 合并进 `main` 且 CI 绿;合完写 `[Unreleased]` |
|
||||
| 2 | 仓库卫生:撤出误入库的 `node_modules/.vite/.../results.json` 与 8 个历史 `scripts/__pycache__/*.pyc` | **未做** | `git ls-files` 反查干净(`git check-ignore -v` 命中规则) |
|
||||
| 3 | `AGENTS.md` 现状表修正稿(写着"main 已启用分支保护",实际 `GET /branch_protections` = `[]`) | 修正稿已拟,**受写入保护待人工批准** | 人工批准后应用,并读回核对 |
|
||||
| 4 | ESLint 接入、格式基线收尾、密钥/依赖扫描器覆盖度 | **未做** | 各自带 `--self-test` 反向自检,接进 `make verify` |
|
||||
|
||||
## 做完之后怎么收尾
|
||||
|
||||
1. 跑 `make verify`(本地全绿不等于远端跑通,需要一次真实 run 的证据)。
|
||||
2. 把**使用者可见**的变化写成 `CHANGELOG.md` 的 `[Unreleased]` 条目(按四类,别写过程)。
|
||||
3. 从本文件划掉该项;过程记录归档到 `docs/archive/`。
|
||||
@@ -0,0 +1,57 @@
|
||||
# 发布说明(正式版本)
|
||||
|
||||
> 这份文档是**破坏性变化与数据迁移步骤的落脚点**:`CHANGELOG.md` 里以 `**破坏性**` 开头的条目
|
||||
> **必须**链接到本文(或本文的某个小节)。发布记录本身仍在 `CHANGELOG.md` —— 本文不维护第二份变更列表。
|
||||
>
|
||||
> 本仓 CI **只做 CI**:不打 tag、不发 Release、不部署。发布与部署都是**独立的人工审批动作**。
|
||||
|
||||
## 一、正式发布(由人批准后执行)
|
||||
|
||||
1. **确认基线**:`git rev-parse HEAD` 拿到完整 40 位 SHA,工作区干净(`git status --porcelain` 为空)。
|
||||
2. **写记录**:把这次要发的东西从 `[Unreleased]` 提到一个正式版本条目
|
||||
`## [x.y.z] - YYYY-MM-DD`(四类小节,无空栏目;破坏性变化写在 `### Changed` 里并链接本文)。
|
||||
版本号**只增不减**(回退走部署层,不改这个文件);每个项目只有本文件这一个版本来源。
|
||||
3. **跑预检(只读)**:`python3 scripts/release_preflight.py`
|
||||
—— 它只列事实(候选版本 / 完整 SHA / 该提交的 CI 证据 / `[Unreleased]` 原文 / 远端同名 tag 与
|
||||
Release 是否已存在 / 遗留风险),有阻塞项就**不给**"可以发"的结论。
|
||||
4. **批准后执行**:`python3 scripts/release_preflight.py --execute --version X.Y.Z
|
||||
--approve-version X.Y.Z --approve-sha <完整SHA>`(两个批准参数必须逐字符等于预检结果)。
|
||||
它创建**注释 tag** `vX.Y.Z`(指向验收过的那个提交)并建关联 Release,正文取自 `CHANGELOG.md`。
|
||||
5. **读回**:`git ls-remote --tags origin` 里该 tag 的 SHA == 本地 `git rev-parse vX.Y.Z^{commit}`;
|
||||
Release 的 `tag_name` 正确。**已发布的 tag 不移动、不覆盖。**
|
||||
|
||||
⚠️ 三个状态分开说:**CI 成功 ≠ 正式发版 ≠ 已部署**。
|
||||
|
||||
## 二、构建身份怎么看
|
||||
|
||||
```bash
|
||||
python3 scripts/version.py --identity # JSON:release_version / build_kind / commit_sha / display_version / built_at
|
||||
```
|
||||
|
||||
| 情形 | `build_kind` | `release_version` | `display_version` |
|
||||
|---|---|---|---|
|
||||
| 普通开发构建(有正式基线) | `development` | 顶部已发布版本 | `X.Y.Z-dev+g<短SHA>` |
|
||||
| 普通开发构建(**还没有正式版本**) | `development` | `null` | `dev+g<短SHA>` |
|
||||
| 发布构建(显式给出发布上下文) | `release` | 顶部已发布版本 | `X.Y.Z`(另带完整 SHA) |
|
||||
|
||||
- `build_kind=release` **只能**来自显式的发布上下文(`BUILD_KIND=release`);普通 `main` CI 永远是
|
||||
`development`,**不得**因为"CHANGELOG 顶部有版本号"就自称正式版。
|
||||
- 二进制里的注入字段(`internal/version`)与上表一一对应;契约测试断言:
|
||||
注入 == CHANGELOG 顶部(发布构建)、开发构建必须是 `dev` 形状(**不许声称正式版**),且**跳过不算通过**。
|
||||
|
||||
## 三、破坏性变化 / 数据迁移怎么写
|
||||
|
||||
在 `CHANGELOG.md` 的 `### Changed` 里:
|
||||
|
||||
```markdown
|
||||
- **破坏性**:接口 `/api/old` 下线,升级步骤见 [迁移步骤](docs/RELEASE_RUNBOOK.md#三破坏性变化--数据迁移怎么写)
|
||||
```
|
||||
|
||||
要求:**必须以 `**破坏性**` 开头**(便于发版前扫出来)、**必须带本文链接**;仓内相对链接会被
|
||||
`scripts/check_changelog.py` **对账**(文件不存在 ⇒ 红)。迁移步骤要写清「先做什么、怎么验证、怎么回退」。
|
||||
|
||||
## 四、回滚
|
||||
|
||||
回滚**不改** `CHANGELOG.md`、**不动** tag:走部署层(恢复上一个已验收的镜像/版本 + 读回证据)。
|
||||
本仓部署流程在 `docs/OPERATIONS_RUNBOOK.md` 与各环境的部署脚本里;部署是**独立审批**,
|
||||
并要记录"实际部署的版本 + 完整 SHA"。
|
||||
+13
-5
@@ -67,20 +67,28 @@
|
||||
|
||||
**反例**:在合并接口上做重试。**永不重试** —— 会得到"第二次合并失败"的假故障。
|
||||
|
||||
## trap-5:CI 检出是 `git fetch --depth 50`,**取不到 tag**
|
||||
## trap-5:浅克隆里 **`git describe --tags` 会静默退化**(≠ 不能用 tag)
|
||||
|
||||
**症状**:想让版本号来自 `git describe --tags`,在 CI 上失败或给出误导结果。
|
||||
|
||||
**根因**:工作流里是 `git fetch -q --depth 50 origin "$BR"`,浅克隆不带 tag。
|
||||
`git describe` 要么报 `No names found`,要么退回到一个和预期无关的短 sha。
|
||||
⚠️ 这是"**不要在构建时靠 `git describe` 取版本号**",**不是**"本仓不用 tag" —— 正式版本
|
||||
**就是**用注释 tag `vX.Y.Z` 标记的(由人批准后创建)。
|
||||
|
||||
**判据**:任何依赖 tag 或完整历史的步骤,必须在 CI 里显式
|
||||
`git fetch --unshallow` 或 `git fetch --tags`;否则就是这条。
|
||||
**判据**:任何依赖 tag 历史的**自动**步骤,必须在 CI 里显式
|
||||
`git fetch --tags` / `--unshallow`;否则就是这条。
|
||||
|
||||
**修法(本仓采用)**:版本号的唯一来源改为仓库里的 `CHANGELOG.md`
|
||||
(`python3 scripts/version.py`),文件一定在,不依赖 tag。
|
||||
**修法(本仓采用)**:
|
||||
- **版本号的唯一来源**是仓库里的 `CHANGELOG.md`(`python3 scripts/version.py`),文件一定在,不依赖 tag;
|
||||
构建身份(含开发标识)用 `scripts/version.py --identity`,`make version` 打印它。
|
||||
- **tag 一致性由人可核验的方式检查**:检出步骤专门落一份远端 tag 清单
|
||||
(`.ci-remote-tags`)并 `git fetch --tags` 取对象,`scripts/check_changelog.py --tags` 再核
|
||||
「tag 版本 == 它指到那个提交里的版本记录」、且必须是**注释 tag**;
|
||||
**没有 tag 不判红**,但**没核对远端就不许说"校验成功"**(CI 用 `--require-remote` 强制)。
|
||||
|
||||
**反例**:`VERSION=$(git describe --tags --always)` —— 本地看着正常,CI 上静默退化。
|
||||
反例二:检查器在拿不到远端 tag 清单时照打 `✅ 校验通过` —— 那是"假绿",现在会明确写「未核对远端」。
|
||||
|
||||
## trap-6:act_runner **不会**自动检出代码
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# 档案:CI 接入与三轮审查修正(原文保留)
|
||||
|
||||
> **这是档案,不是发布记录。** 内容从 `CHANGELOG.md` 的 `[Unreleased]` 段原文迁出(2026-10-13 整理),
|
||||
> 保留当时的命令、输出摘要与逐条修正明细,**不直接丢弃**。发布记录只保留使用者可见的变化。
|
||||
>
|
||||
> 迁出时的仓库基线:完整 SHA `c99332d7705db516ed549e1d12fe60513a70581e`(`main`)。
|
||||
> 相关约定:唯一权威门禁 `make verify`;CI 只做 CI 不做 CD;版本号唯一来源 = 仓库根 `CHANGELOG.md`。
|
||||
|
||||
## 一、CI 接入这一批
|
||||
|
||||
**唯一权威门禁**:`make verify` = `python3 scripts/ci_checks.py` —— 一条命令跑完 14 项必需检查、
|
||||
串行、失败即停,产出结构化报告 `ci-report.json`。本地与 CI 跑的是**同一条命令**(不再各写一套)。
|
||||
|
||||
- 新增 14 项检查:`identity`(检出 == 事件完整 SHA)、`commits`、`deps-install`(按锁安装)、
|
||||
`format`(Go gofmt + 前端 prettier)、`static-go`(go vet)、`static-web`(tsc)、
|
||||
`build-go`(带版本注入)、`build-web`、`tests-go`、`tests-web`、`contracts`(版本契约**必须真 PASS**)、
|
||||
`security-secrets`、`security-deps`(govulncheck + npm audit 分级 + 例外)、`manifests-locked`。
|
||||
- 汇总按**清单**逐项核对:失败即停后剩下的记 `not_run`,**未运行/被跳过一律不算通过**。
|
||||
- `.gitea/workflows/build.yml` 改成**单作业**(避免跨作业 `/tmp` 状态传递的假契约):检出固定事件完整 SHA →
|
||||
环境与工具链 → `make verify` → 静态预览(**只在全部必需检查通过后**发布,且断言包里有 `index.html`、
|
||||
报告里每个地址都 200)→ 上报 ci-bridge(**通知失败不改变质量结论**)。
|
||||
- 本 workflow **只做 CI**:删掉了 `deploy:test` 占位作业(原来它会打印 TODO 然后返回成功 = 假报成功)。
|
||||
- 新增脚本:`scripts/ci_checks.py`(门禁 + 汇总)、`scripts/check_format.py`(格式,含只能减的基线)、
|
||||
`scripts/check_secrets.py`(密钥扫描,带双向自检)、`scripts/check_deps_vuln.py`(依赖漏洞分级)。
|
||||
四个脚本都带 `--self-test`(反向自检:故意弄坏必须红)。
|
||||
- `security/dependency-exceptions.json`:依赖漏洞例外登记(缺字段/过期/批准人是占位值 ⇒ 例外无效)。
|
||||
- 依赖:`vite` 7.1.3 → **7.3.7**、`vitest` 3.2.4 → **5.0.3**(清掉 npm audit 的 2 critical + 2 high + 1 moderate,
|
||||
现在全量 0 命中);新增 `prettier` 3.9.10;`web/package.json` 加 `format` / `format:check`。
|
||||
- 格式债:Go 侧 6 个文件 `gofmt -w`(存量清干净,Go 侧一条都不欠);前端 79 个文件的存量格式债
|
||||
记入 `web/.prettier-baseline.json`(**列明 + 只能减 + 2026-12-31 到期**,不新增欠账即绿)。
|
||||
- `AGENTS.md` 的现状表**已过期**(写着"main 已启用分支保护",实际 `GET /branch_protections` = `[]`)——
|
||||
修正稿已拟好,但该文件受写入保护,**待人工批准后应用**(见交付说明)。
|
||||
|
||||
### 怎么验收(当时的实测命令)
|
||||
|
||||
```bash
|
||||
# 1) 门禁自身可跑、且能证明自己会红(反向自检)
|
||||
python3 scripts/ci_checks.py --self-test # 期望:✅ 汇总逻辑 7 个分支符合预期
|
||||
python3 scripts/check_format.py --self-test # 期望:✅ 6 个判定分支
|
||||
python3 scripts/check_secrets.py --self-test # 期望:✅ 7 条合成密钥全部命中,良性对照零误报
|
||||
python3 scripts/check_deps_vuln.py --self-test # 期望:✅ 9 个判据 + JSON 流解析器
|
||||
|
||||
# 2) 一条命令跑完全部门禁(约 1 分钟;会跑 npm ci,请留时间)
|
||||
make verify
|
||||
# 期望:末尾"总体:success(档案 local)",identity=skipped(本地无事件负载,已在清单里声明)
|
||||
|
||||
# 3) 清单与真实结果对账
|
||||
make list # 期望:14 项,identity 标 ci
|
||||
python3 -c "import json;d=json.load(open('ci-report.json'));print(d['overall_status'],len(d['checks']))"
|
||||
# 期望:success 14
|
||||
```
|
||||
|
||||
## 二、第二轮审查修正(GPT)
|
||||
|
||||
审查在**通用模板**与**预览发布顺序**上找出 6 处,逐条修完并补了负例:
|
||||
|
||||
- **P0 通用模板的 Python 检查是空跑** → `templates/ci-gate.py` 的 `deps-install/format/static/build/tests`
|
||||
对 Python 现在**真的执行**(`uv sync` / `uv run ruff` / `compileall` / `uv run pytest`);
|
||||
工具缺失 ⇒ 明确失败(`工具缺失:uv(…不空跑)`),不再"没写检查所以返回成功"。
|
||||
前端测试**进程退出码也算判据**:报告写 pass 但进程退出 1 ⇒ 红(实测过)。
|
||||
前端框架不再写死 vitest:读 `scripts/test-scope.json` 的声明,认不出来 ⇒ 明确失败。
|
||||
通用清单补了 `security`(密钥扫描 + 依赖漏洞),工具缺失/输出解析不了 ⇒ 红。
|
||||
- **P0 预览发布早于报告完整验证** → 预览逻辑移进 `.gitea/scripts/ci_preview.py`:**先**调
|
||||
`ci_report.verify()`(与上报同一份核对逻辑),有问题就**一个 publish 请求都不发**;
|
||||
通过后才打包(断言包内有 `index.html`、无 `.` 根成员)→ POST → 报告里每个地址都探测 200。
|
||||
`scripts/ci_preview_negative_test.py` 用**假 bridge 记录请求**证明:10 个坏报告分支 publish 次数都是 0,
|
||||
合规报告正好 1 次。
|
||||
- **P1 提交范围可能漏查** → `.gitea/scripts/ci_gate.py` 以**准确 Git 范围**为准(push 事件用
|
||||
`before..after`,新分支以远端默认分支为基准),事件声明的条数与列表/Git 范围不一致 ⇒ 拒绝;
|
||||
列表被截断但 Git 范围可用 ⇒ 以 Git 为准并写明;空范围、浅克隆取不到 `before`、列表提交不在检出里
|
||||
⇒ 全部拒绝;手工运行只核 HEAD 但**写明**这一点。`--self-test` 覆盖 11 个分支。
|
||||
`.gitea/scripts/ci_checkout.sh` 顺带把事件 `before` 也取下来(否则范围算不出来)。
|
||||
- **P1 合并必需检查可以为空** → `adopt-existing.py` 的必需 context 只能来自**明确策略**
|
||||
(`--require-context` 或仓库里的 `.gitea/required-contexts.txt`);两个都没有 / 文件是空的 ⇒ 拒绝合并,
|
||||
**不再用"体检观察到的状态"顶替**。同时核对 PR 目标分支 == 默认分支、`state=open`、head 仓库就是本仓。
|
||||
`--self-test-chain`(假 Gitea + 真实 `merge_pr()`)证明 13 个分支里只有"策略齐 + 必需 context 全 success
|
||||
+ 完整获批 SHA + base/state/来源都对"才发出 merge 请求。
|
||||
- **P1 协议被静默降级** → 检出逻辑移进 `.gitea/scripts/ci_checkout.sh`,remote 直接用
|
||||
`GITHUB_SERVER_URL` 原样拼接并**断言没有降级**(https 不许变 http)、URL 里不许带凭据、
|
||||
检出必须等于事件完整 SHA。`--print-remote` 可本地验证协议保留。
|
||||
|
||||
## 三、第三轮审查修正(启动与范围)
|
||||
|
||||
第三轮实际复现了两个会**卡住真实 CI** 的问题,逐条修完并补了完整调用链夹具:
|
||||
|
||||
- **P0 空工作区起不来** → 旧写法把"检出"写成仓内脚本(`bash .gitea/scripts/ci_checkout.sh`),
|
||||
而这个 Runner 不自动检出 ⇒ 工作区为空时脚本不存在,实测 **rc=127**。现在 workflow 第一步是
|
||||
**最小 bootstrap**(只用 runner 的环境变量:校验协议/凭据 → `git init` + `remote add` →
|
||||
取**事件完整 SHA** → checkout → 断言 HEAD==事件 SHA),检出完成、脚本就位后才调用
|
||||
`ci_checkout.sh --post-checkout` 做再断言与补取(默认分支引用、事件 `before`)。
|
||||
顺带修掉"按任意 SHA `fetch`"——服务端默认不允许(`服务器不允许请求未公开的对象`),改为
|
||||
"取分支历史 + `--deepen` 再核事件 SHA"。
|
||||
- **P1 新分支范围恒为空** → `after --not --remotes` 用的是**刚 fetch 的同名远端分支**,
|
||||
它等于 `after`,于是把自身排除、范围恒为 0 条。现在新分支的基准是**远端默认分支**
|
||||
(`repository.default_branch`):范围 = `refs/remotes/origin/<default>..<after>`;
|
||||
取不到基准 ⇒ 明确失败,绝不用整部历史或空集糊过去。
|
||||
- **P1 通用模板 Python 未锁定** → `templates/ci-gate.py` 不再有"缺锁就 `uv sync`"和
|
||||
`uv pip install --system`:必须有 `uv.lock` 才装(`uv sync --frozen`),所有检查走
|
||||
`uv run --frozen`(项目内 `.venv` 隔离),并断言 `sys.prefix != sys.base_prefix`;
|
||||
只有 `requirements.txt` ⇒ 明确失败并要求先生成锁文件。
|
||||
- **新增完整调用链夹具** `scripts/ci_chain_fixture.py`:本地 HTTP git 服务(真 `git http-backend`)+
|
||||
**把 workflow 里的 bootstrap `run:` 块原样抽出来在空目录执行** → 再调用仓内脚本 →
|
||||
再跑提交范围闸门。正例(检出到事件 SHA、协议保留、合规新分支通过)与负例
|
||||
(旧写法 127、取不到 SHA 失败、含违规提交的新分支判红且**红在那条提交上**)共 12 项断言。
|
||||
- **第一次真实 Runner 首跑又抓到一处**:新分支推送时 Gitea 的 `total_commits=0`、`commits` 列表被截断(5 条),
|
||||
而 Git 范围是 12 条 —— 原先"声明条数 ≠ Git 范围就拒绝"的严判让门禁在真实 CI 上第一次就红了,且与代码质量无关。
|
||||
改成:**Git 范围是唯一权威**,`total_commits > 0` 才交叉核对,`<=0`/缺失视为"未声明"并把
|
||||
"负载条数不可信、列表被截断"写进来源说明。
|
||||
|
||||
## 四、当时的遗留状态
|
||||
|
||||
- 本地检查通过 ≠ 远端 CI 已跑通;当时两仓**均未推送**。
|
||||
- ESLint 接入、格式基线收尾、扫描器覆盖度等未做项见 `docs/BACKLOG.md`。
|
||||
+319
-191
@@ -2,27 +2,36 @@
|
||||
"""CHANGELOG / 版本记录检查 —— 把"版本规则"变成退出码(正本 §6)。
|
||||
|
||||
判据(每条都能被 --self-test 证明会红):
|
||||
|
||||
A. 结构(默认就跑)
|
||||
1. 至少一个已发布版本条目;`[Unreleased]` 段若有,必须在最上面、可以只有 `改了什么`;
|
||||
2. 已发布版本标题 `## [x.y.z] - YYYY-MM-DD`:三段数字、**不许前导零**、日期必备;
|
||||
3. 每个已发布版本块必须有**四节**:`改了什么`、`兼容性与迁移影响`、`怎么验收`、`已知问题`
|
||||
("已知问题"可以写“无”,但不能整节空着 —— 空节等于没写);
|
||||
4. 版本号不重复、按出现顺序严格递减(最新在最上面);
|
||||
5. 已发布版本块本身不为空。
|
||||
1. `[Unreleased]` 段若有,必须在最上面;**只有 `[Unreleased]`、还没有任何正式版本**是合法的
|
||||
(开发期常态,不许因此判红);
|
||||
2. 已发布版本标题 `## [x.y.z] - YYYY-MM-DD`:三段十进制、**不许前导零**、**日期必备且必须是真日期**;
|
||||
3. 小节**只认四类** `Added` / `Changed` / `Fixed` / `Security`:
|
||||
**分类可省略**(不要求每个提交都写),但**不保留空栏目** —— 写了就得有内容;
|
||||
每个版本块至少有一个非空分类(整块空白 ⇒ 红);
|
||||
4. **破坏性变化**写在 `### Changed` 里、以 `**破坏性**` 开头,且必须**链接发布文档**;
|
||||
仓内相对链接(`docs/….md`)会被**对账**:文件不存在 ⇒ 红(防"链接到不存在的 runbook");
|
||||
5. 版本号不重复、按出现顺序**严格递减**(最新在最上面);已发布版本块本身不为空。
|
||||
|
||||
B. tag 一致性(`--tags` / CI 里跑)
|
||||
6. 正式 tag 形如 `v<版本号>`,且必须是**注释 tag**(轻量 tag 不合格);
|
||||
7. 每个正式 tag 指到的那个提交里,`CHANGELOG.md` 顶部的已发布版本 == 该 tag 的版本
|
||||
(防止"tag 指着的提交从没被记进变更记录");
|
||||
8. **没有 tag 是正常的**:开发版本不打 tag 绝不能让日常 CI 变红;
|
||||
看不到远端 tag 时(缺 `.ci-remote-tags`)明说"没核对远端",不假绿。
|
||||
7. 每个正式 tag **指到的那个提交里**的 CHANGELOG 顶部已发布版本 == 该 tag 的版本
|
||||
(防止"tag 指着的提交从没被记进变更记录");**用那个提交的内容核对,不用当前 HEAD**;
|
||||
8. **没有 tag 是正常的**(开发版本不打 tag 绝不能让日常 CI 变红);
|
||||
9. **没核对远端就不许说"校验成功"**:本地模式明确打印「未核对远端」;
|
||||
CI 用 `--require-remote`(或设 `CI=true`)⇒ 拿不到远端 tag 清单**直接判红**。
|
||||
|
||||
用法:
|
||||
python3 scripts/check_changelog.py # 只查结构
|
||||
python3 scripts/check_changelog.py --tags # 结构 + 本地 tag 一致性
|
||||
python3 scripts/check_changelog.py --self-test # 反向自检(含造 tag 的夹具)
|
||||
python3 scripts/check_changelog.py # 只查结构
|
||||
python3 scripts/check_changelog.py --tags # 结构 + tag 一致性
|
||||
python3 scripts/check_changelog.py --tags --require-remote # CI 用:远端清单缺失即红
|
||||
python3 scripts/check_changelog.py --self-test # 反向自检(含造真仓库的 tag 夹具)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -31,71 +40,152 @@ 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)
|
||||
HEADING = re.compile(r"^##\s*\[([^\]]+)\](\s*-\s*(\d{4}-\d{2}-\d{2}))?\s*$")
|
||||
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") # 不许前导零(01.2.3 不合格)
|
||||
TAG_RE = re.compile(r"^v(\d+\.\d+\.\d+)$")
|
||||
SEC_CHANGE = "### 改了什么"
|
||||
SEC_COMPAT = "### 兼容性与迁移影响"
|
||||
SEC_ACCEPT = "### 怎么验收"
|
||||
SEC_KNOWN = "### 已知问题"
|
||||
REQUIRED_SECS = (SEC_CHANGE, SEC_COMPAT, SEC_ACCEPT, SEC_KNOWN)
|
||||
SEC_H = re.compile(r"^###\s+(.+?)\s*$")
|
||||
CATEGORIES = ("Added", "Changed", "Fixed", "Security")
|
||||
BREAKING = "**破坏性**"
|
||||
# 破坏性变化条目:允许列表项前缀(`- **破坏性**:…` / `1. **破坏性**:…`)
|
||||
BREAK_LINE = re.compile(r"^\s*(?:[-*+]|\d+[.)])?\s*\*\*破坏性\*\*")
|
||||
REMOTE_TAGS = ROOT / ".ci-remote-tags"
|
||||
LINK = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
|
||||
|
||||
|
||||
# ── 结构解析(**围栏代码块内的 `##`/`###` 不算标题**)─────────────────────
|
||||
def _blocks(text: str):
|
||||
"""版本块:每块的正文到**下一个二级标题**为止(`### x` 不算)。
|
||||
|
||||
⚠️ 边界必须是**任意** `## ` 标题,不只是 `## [x.y.z]`:像「## 能力基线(未发布)」这种
|
||||
不带方括号的二级标题若不当边界,它的正文会被算进上一个版本块 ⇒ 让"空栏目"看起来有内容
|
||||
(夹具真的抓到过这个假绿)。
|
||||
"""
|
||||
lines = text.splitlines()
|
||||
fence = None
|
||||
heads: list[tuple[int, bool, str, str | None]] = []
|
||||
for i, line in enumerate(lines):
|
||||
s = line.strip()
|
||||
if fence is None and s.startswith("```"):
|
||||
fence = "```"
|
||||
continue
|
||||
if fence is not None:
|
||||
if s.startswith("```"):
|
||||
fence = None
|
||||
continue
|
||||
if not re.match(r"^##\s", line): # `### x` 不匹配:`##` 后面必须是空白
|
||||
continue
|
||||
m = HEADING.match(line)
|
||||
heads.append((i, bool(m), m.group(1) if m else "", m.group(3) if m else None))
|
||||
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]))
|
||||
for k, (i, is_version, label, date) in enumerate(heads):
|
||||
end = heads[k + 1][0] if k + 1 < len(heads) else len(lines)
|
||||
if is_version: # 不带版本号的二级标题只当边界,本身不校验
|
||||
out.append((lines[i], label, date, lines[i + 1:end]))
|
||||
return out
|
||||
|
||||
|
||||
def _sec_body(body: str, sec: str) -> str:
|
||||
tail = body.split(sec, 1)[1]
|
||||
return re.split(r"^###\s", tail, maxsplit=1, flags=re.MULTILINE)[0]
|
||||
def _sections(body_lines: list[str]):
|
||||
"""返回 {小节名: 内容行} 与出现顺序。围栏内的行归入当前小节(不算标题)。"""
|
||||
out: dict[str, list[str]] = {}
|
||||
order: list[str] = []
|
||||
cur = ""
|
||||
fence = False
|
||||
for line in body_lines:
|
||||
s = line.strip()
|
||||
if s.startswith("```"):
|
||||
fence = not fence
|
||||
if cur:
|
||||
out[cur].append(line)
|
||||
continue
|
||||
m = None if fence else SEC_H.match(line)
|
||||
if m:
|
||||
cur = m.group(1).strip()
|
||||
out.setdefault(cur, [])
|
||||
order.append(cur)
|
||||
continue
|
||||
if cur:
|
||||
out[cur].append(line)
|
||||
return out, order
|
||||
|
||||
|
||||
def check(path: Path | None = None) -> list[str]:
|
||||
def _nonempty(lines: list[str]) -> bool:
|
||||
return any(l.strip() and not l.strip().startswith("```") for l in lines)
|
||||
|
||||
|
||||
def _date_ok(s: str) -> bool:
|
||||
try:
|
||||
dt.date.fromisoformat(s)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _doc_link_problem(line: str, root: Path) -> str | None:
|
||||
"""破坏性变化那行必须带一个能用的发布文档链接;返回问题描述(None = 没问题)。"""
|
||||
targets = LINK.findall(line)
|
||||
if not targets:
|
||||
return "没有链接(要指向发布文档,写清升级/迁移步骤)"
|
||||
for t in targets:
|
||||
clean = t.split("#", 1)[0].split("?", 1)[0]
|
||||
if clean.startswith(("http://", "https://")):
|
||||
return None
|
||||
if not clean.endswith(".md"):
|
||||
continue
|
||||
if (root / clean).is_file():
|
||||
return None
|
||||
return f"链接的发布文档不存在:{clean}"
|
||||
return f"链接目标不是文档(要 `docs/….md` 或 https 链接):{targets[0]}"
|
||||
|
||||
|
||||
def check(path: Path | None = None, root: Path | None = None) -> list[str]:
|
||||
path = path or (ROOT / "CHANGELOG.md")
|
||||
root = root or ROOT
|
||||
if not path.is_file():
|
||||
return [f"找不到 {path}"]
|
||||
text = path.read_text(encoding="utf-8")
|
||||
blocks = _blocks(text)
|
||||
blocks = _blocks(path.read_text(encoding="utf-8"))
|
||||
released = [b for b in blocks if b[1] != "Unreleased"]
|
||||
if not released:
|
||||
# ⚠️ "还没有发过版"不是错误:新工程只有 `[Unreleased]` 是正常的 ——
|
||||
# 首次发版时把 Unreleased 改成 `## [0.1.0] - YYYY-MM-DD` 并补齐四节即可。
|
||||
# (真一个段都没有,才是"没有版本记录"。)
|
||||
if any(b[1] == "Unreleased" for b in blocks):
|
||||
return []
|
||||
return ["没有已发布版本条目(形如 `## [0.1.0] - 2026-10-10`)—— 也没有 `[Unreleased]` 段"]
|
||||
if not released and not any(b[1] == "Unreleased" for b in blocks):
|
||||
return ["没有版本记录:既没有已发布版本条目(`## [0.1.0] - 2026-10-10`),也没有 `[Unreleased]` 段"]
|
||||
|
||||
problems: list[str] = []
|
||||
# [Unreleased] 必须在最上面(日常改动先记这里)
|
||||
if blocks and blocks[0][1] == "Unreleased":
|
||||
pass
|
||||
elif any(b[1] == "Unreleased" for b in blocks):
|
||||
if any(b[1] == "Unreleased" for b in blocks) and blocks[0][1] != "Unreleased":
|
||||
problems.append("[Unreleased] 段必须在最上面(日常改动先记这里,发版时把它改成版本号)")
|
||||
|
||||
seen: list[str] = []
|
||||
for heading, ver, date, body in released:
|
||||
if ver == "Unreleased":
|
||||
for heading, label, date, body in blocks:
|
||||
secs, order = _sections(body)
|
||||
unknown = [s for s in order if s not in CATEGORIES]
|
||||
if unknown:
|
||||
problems.append(f"{heading} 有小节不属于允许的四类({'/'.join(CATEGORIES)}):{unknown}"
|
||||
f"(分类可省略,但不能自创栏目)")
|
||||
empty = [s for s in order if s in CATEGORIES and not _nonempty(secs[s])]
|
||||
if empty:
|
||||
problems.append(f"{heading} 的 {empty} 是空栏目(写了就得有内容;不需要就整节删掉)")
|
||||
if not [s for s in order if s in CATEGORIES and _nonempty(secs[s])]:
|
||||
problems.append(f"{heading} 没有任何有内容的分类(至少写一条使用者可见的变化)")
|
||||
for name in order:
|
||||
if name != "Changed" and any(BREAK_LINE.match(l) for l in secs.get(name, [])):
|
||||
problems.append(f"{heading} 的 `{name}` 里有破坏性变化条目 —— "
|
||||
f"破坏性变化必须写在 `### Changed` 里(位置固定,便于发版前扫出来)")
|
||||
for line in secs.get("Changed", []):
|
||||
if BREAK_LINE.match(line):
|
||||
bad = _doc_link_problem(line, root)
|
||||
if bad:
|
||||
problems.append(f"{heading} 的破坏性变化不合规:{bad}")
|
||||
|
||||
if label == "Unreleased":
|
||||
if date:
|
||||
problems.append(f"{heading} 是未发布段,不该写日期")
|
||||
continue
|
||||
if not SEMVER.match(ver):
|
||||
problems.append(f"{heading} 的版本号不合法(要 `主.次.修订` 三段十进制、不许前导零):{ver!r}")
|
||||
if not SEMVER.match(label):
|
||||
problems.append(f"{heading} 的版本号不合法(要 `主.次.修订` 三段十进制、不许前导零):{label!r}")
|
||||
continue
|
||||
seen.append(ver)
|
||||
seen.append(label)
|
||||
if not date:
|
||||
problems.append(f"{heading} 缺少日期(要写成 `## [{ver}] - YYYY-MM-DD`)")
|
||||
for sec in REQUIRED_SECS:
|
||||
if sec not in body:
|
||||
problems.append(f"[{ver}] 缺少小节 `{sec}` —— 正式版本必须记录"
|
||||
f"改了什么 / 兼容性与迁移影响 / 怎么验收 / 已知问题")
|
||||
continue
|
||||
if not [l for l in _sec_body(body, sec).splitlines()
|
||||
if l.strip() and not l.strip().startswith("```")][:1]:
|
||||
problems.append(f"[{ver}] 的 `{sec}` 下面是空的(不知道就写“无”,但不能空着)")
|
||||
problems.append(f"{heading} 缺少日期(要写成 `## [{label}] - YYYY-MM-DD`)")
|
||||
elif not _date_ok(date):
|
||||
problems.append(f"{heading} 的日期不是合法日期:{date!r}")
|
||||
|
||||
dupes = {v for v in seen if seen.count(v) > 1}
|
||||
if dupes:
|
||||
problems.append(f"版本号重复:{sorted(dupes)}")
|
||||
@@ -105,36 +195,43 @@ def check(path: Path | None = None) -> list[str]:
|
||||
return problems
|
||||
|
||||
|
||||
# ── tag 一致性 ───────────────────────────────────────────────────────────
|
||||
def _git(*args, cwd=None) -> tuple[int, str]:
|
||||
p = subprocess.run(["git", *args], cwd=cwd or ROOT, capture_output=True, text=True)
|
||||
return p.returncode, (p.stdout if p.returncode == 0 else p.stderr)
|
||||
|
||||
|
||||
def remote_tag_lines() -> tuple[list[str], str]:
|
||||
"""tag 清单,每行「对象sha + refs/tags/<名>」。
|
||||
def remote_tag_lines(cwd: Path | None = None) -> tuple[list[str], str, bool]:
|
||||
"""tag 清单,每行「对象 sha + refs/tags/<名>」。
|
||||
|
||||
取数顺序:① 检出步骤写的 `.ci-remote-tags`(CI 用,能证明远端有哪些 tag);
|
||||
② 本地 `for-each-ref refs/tags`(**没核对远端**,来源里必须写明);③ `git ls-remote`。
|
||||
返回 (行, 来源说明, 是否核对过远端)。顺序:
|
||||
① 检出步骤写的 `.ci-remote-tags`(CI,能证明远端有哪些 tag);
|
||||
② `git ls-remote --tags origin`(真查远端 → 算核对过);
|
||||
③ 本地 `for-each-ref refs/tags`(**没核对远端**)。
|
||||
"""
|
||||
if REMOTE_TAGS.is_file():
|
||||
return [l for l in REMOTE_TAGS.read_text(encoding="utf-8").splitlines() if l.strip()], \
|
||||
f"远端清单 {REMOTE_TAGS.name}"
|
||||
import subprocess as _sp
|
||||
q = _sp.run(["git", "for-each-ref", "--format=%(objectname) %(refname)", "refs/tags"],
|
||||
cwd=ROOT, capture_output=True, text=True)
|
||||
if q.returncode == 0 and q.stdout.strip():
|
||||
return [l for l in q.stdout.splitlines() if l.strip()], "本地 refs/tags(**只看得到本机取到的 tag,没核对远端**)"
|
||||
rc, out = _git("ls-remote", "--tags", "origin")
|
||||
if rc == 0:
|
||||
return [l for l in out.splitlines() if l.strip()], "git ls-remote --tags origin(本机实时查)"
|
||||
return [], "拿不到任何 tag 信息(本机没有 tag,也查不了远端)"
|
||||
cwd = cwd or ROOT
|
||||
f = cwd / ".ci-remote-tags"
|
||||
if f.is_file():
|
||||
return [l for l in f.read_text(encoding="utf-8").splitlines() if l.strip()], \
|
||||
f"远端清单 {f.name}(检出步骤写入)", True
|
||||
p = subprocess.run(["git", "ls-remote", "--tags", "origin"], cwd=cwd,
|
||||
capture_output=True, text=True)
|
||||
if p.returncode == 0:
|
||||
return [l for l in p.stdout.splitlines() if l.strip()], "git ls-remote --tags origin(本机实时查远端)", True
|
||||
q = subprocess.run(["git", "for-each-ref", "--format=%(objectname) %(refname)", "refs/tags"],
|
||||
cwd=cwd, capture_output=True, text=True)
|
||||
if q.returncode == 0:
|
||||
return [l for l in q.stdout.splitlines() if l.strip()], "本地 refs/tags(**未核对远端**)", False
|
||||
return [], "拿不到任何 tag 信息(本机没有 tag,也查不了远端)", False
|
||||
|
||||
|
||||
def check_tags(changelog: Path | None = None) -> list[str]:
|
||||
def check_tags(changelog: Path | None = None, *, require_remote: bool = False,
|
||||
cwd: Path | None = None) -> list[str]:
|
||||
"""正式 tag 的版本必须与**它指到的那个提交里**的版本记录一致。没有 tag ⇒ 不是问题。"""
|
||||
changelog = changelog or (ROOT / "CHANGELOG.md")
|
||||
cwd = cwd or ROOT
|
||||
changelog = changelog or (cwd / "CHANGELOG.md")
|
||||
problems: list[str] = []
|
||||
lines, source = remote_tag_lines()
|
||||
lines, source, consulted = remote_tag_lines(cwd)
|
||||
formal, notes = [], []
|
||||
for line in lines:
|
||||
parts = line.split()
|
||||
@@ -146,20 +243,28 @@ def check_tags(changelog: Path | None = None) -> list[str]:
|
||||
continue
|
||||
m = TAG_RE.match(name)
|
||||
(formal if m else notes).append((name, sha, m.group(1) if m else ""))
|
||||
print(f"-- tag 核对:来源 {source}|正式 tag {len(formal)} 个"
|
||||
f"{'|非正式(不判版本一致性):' + ', '.join(n for n, _, _ in notes) if notes else ''}")
|
||||
print(f"-- tag 核对:来源 {source}|{'已核对远端' if consulted else '⚠️ 未核对远端'}"
|
||||
f"|正式 tag {len(formal)} 个"
|
||||
f"{'|非正式(不当作发布版本、不判版本一致性):' + ', '.join(n for n, _, _ in notes) if notes else ''}")
|
||||
if not consulted:
|
||||
msg = ("没核对远端 tag 信息 ⇒ 不能声称 tag 校验成功"
|
||||
"(CI 应传入 `.ci-remote-tags`,或让 `git ls-remote --tags origin` 可用)")
|
||||
if require_remote:
|
||||
problems.append(msg)
|
||||
else:
|
||||
print(f" ⚠️ {msg}(本机模式:只报事实,不判红)")
|
||||
|
||||
for name, sha, ver in formal:
|
||||
rc, commit = _git("rev-parse", sha + "^{commit}")
|
||||
rc, commit = _git("rev-parse", sha + "^{commit}", cwd=cwd)
|
||||
if rc != 0 or not commit.strip():
|
||||
problems.append(f"tag {name}({sha[:12]})指向的提交在本机取不到 —— "
|
||||
f"没法核对它的版本记录(检出步骤应当把 tag 取下来)")
|
||||
continue
|
||||
commit = commit.strip()
|
||||
rc, kind = _git("cat-file", "-t", sha)
|
||||
rc, kind = _git("cat-file", "-t", sha, cwd=cwd)
|
||||
if rc == 0 and kind.strip() == "commit":
|
||||
problems.append(f"tag {name} 是**轻量 tag**(不是注释 tag)—— 正式发布必须用注释 tag")
|
||||
rc, blob = _git("show", commit + ":CHANGELOG.md")
|
||||
rc, blob = _git("show", commit + ":CHANGELOG.md", cwd=cwd)
|
||||
if rc != 0:
|
||||
problems.append(f"tag {name} 指到的提交里没有 CHANGELOG.md")
|
||||
continue
|
||||
@@ -176,165 +281,186 @@ def check_tags(changelog: Path | None = None) -> list[str]:
|
||||
return problems
|
||||
|
||||
|
||||
GOOD = """# 变更记录
|
||||
# ── 自检夹具 ─────────────────────────────────────────────────────────────
|
||||
GOOD = """# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 改了什么
|
||||
|
||||
- 还没发布的东西
|
||||
### Added
|
||||
- 新东西
|
||||
|
||||
## [0.2.0] - 2026-02-01
|
||||
|
||||
### 改了什么
|
||||
### Changed
|
||||
- 调整了 X 的行为
|
||||
- **破坏性**:接口 `/old` 下线,升级步骤见 [发布说明](docs/RELEASE_RUNBOOK.md)
|
||||
|
||||
- 加了 X
|
||||
|
||||
### 兼容性与迁移影响
|
||||
|
||||
- 无(向后兼容)
|
||||
|
||||
### 怎么验收
|
||||
|
||||
1. 跑 `curl /health` 期望 200
|
||||
|
||||
### 已知问题
|
||||
|
||||
- 无
|
||||
### Fixed
|
||||
- 修了 Y
|
||||
|
||||
## [0.1.0] - 2026-01-01
|
||||
|
||||
### 改了什么
|
||||
|
||||
### Added
|
||||
- 初版
|
||||
"""
|
||||
|
||||
### 兼容性与迁移影响
|
||||
BAD = """# Changelog
|
||||
|
||||
- 首次发布,无迁移
|
||||
## [0.1.0] - 2026-02-30
|
||||
|
||||
### 怎么验收
|
||||
### Added
|
||||
|
||||
1. 起容器,打开首页
|
||||
- 日期不存在,还引了不存在的文档
|
||||
- **破坏性**:删掉了旧接口,见 [说明](docs/NO_SUCH_DOC.md)(这里破坏性写在 Added 里,也要报)
|
||||
|
||||
### 已知问题
|
||||
|
||||
- 集成用例需要 TEST_DATABASE_URL,CI 里跳过(已声明)
|
||||
"""
|
||||
|
||||
BAD = """# 变更记录
|
||||
|
||||
## [0.1.0]
|
||||
|
||||
### 改了什么
|
||||
|
||||
- 没有日期、缺两节、而且版本顺序还倒着
|
||||
- 自创栏目
|
||||
|
||||
## [0.2.0] - 2026-02-01
|
||||
|
||||
### 改了什么
|
||||
### Changed
|
||||
- 顺序倒置(这条比上面那条大却排在下面)
|
||||
- **破坏性**:改了配置格式(没链接)
|
||||
|
||||
- 上面那条比这条旧,顺序错了(版本号合法,但比上面的大)
|
||||
## [0.2.0] - 2026-03-01
|
||||
|
||||
### Changed
|
||||
- **破坏性**:删掉了 `/old` 接口,升级步骤见 [说明](docs/NO_SUCH_DOC.md)
|
||||
|
||||
### Fixed
|
||||
|
||||
## [01.0.0] - 2026-03-02
|
||||
|
||||
### Fixed
|
||||
- 前导零
|
||||
"""
|
||||
|
||||
|
||||
def _seed_repo(d: Path, changelog_text: str, tag: str, annotated: bool, tag_ver: str) -> str:
|
||||
"""造一个最小 git 仓库:一个提交 + 一个 tag(指向该提交,tag 名与提交内版本可刻意不一致)。"""
|
||||
env = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||
def _seed_repo(d: Path, changelog_text: str, tag: str, annotated: bool) -> str:
|
||||
"""造一个最小 git 仓库:一个提交 + 一个 tag(指向该提交)。"""
|
||||
env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
|
||||
"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.hooksPath",
|
||||
"GIT_CONFIG_VALUE_0": str(d / "no-hooks")}
|
||||
(d / "no-hooks").mkdir(exist_ok=True)
|
||||
for args in (["init", "-q", "-b", "main"],):
|
||||
subprocess.run(["git", *args], cwd=d, env={**dict(**__import__("os").environ), **env}, check=True)
|
||||
subprocess.run(["git", "init", "-q", "-b", "main"], cwd=d, env=env, check=True)
|
||||
(d / "CHANGELOG.md").write_text(changelog_text, encoding="utf-8")
|
||||
for args in (["add", "-A"], ["commit", "-q", "-m", "chore(release): 夹具提交"],
|
||||
(d / "docs").mkdir(exist_ok=True)
|
||||
(d / "docs" / "RELEASE_RUNBOOK.md").write_text("# 发布说明\n", encoding="utf-8")
|
||||
for args in (["add", "-A"], ["commit", "-q", "-m", "chore: 夹具提交"],
|
||||
["tag", "-a", tag, "-m", f"release {tag}"] if annotated else ["tag", tag]):
|
||||
subprocess.run(["git", *args], cwd=d, env={**dict(**__import__("os").environ), **env}, check=True)
|
||||
return subprocess.run(["git", "rev-parse", "HEAD"], cwd=d, capture_output=True,
|
||||
text=True).stdout.strip()
|
||||
subprocess.run(["git", *args], cwd=d, env=env, check=True)
|
||||
return subprocess.run(["git", "rev-parse", "HEAD"], cwd=d,
|
||||
capture_output=True, text=True).stdout.strip()
|
||||
|
||||
|
||||
def self_test() -> int:
|
||||
import os
|
||||
problems: list[str] = []
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
g, b = Path(d) / "good.md", Path(d) / "bad.md"
|
||||
dd = Path(d)
|
||||
(dd / "docs").mkdir()
|
||||
(dd / "docs" / "RELEASE_RUNBOOK.md").write_text("# 发布说明\n", encoding="utf-8")
|
||||
g, b = dd / "good.md", dd / "bad.md"
|
||||
g.write_text(GOOD, encoding="utf-8")
|
||||
b.write_text(BAD, encoding="utf-8")
|
||||
if check(g):
|
||||
problems.append(f"合规夹具竟然报红:{check(g)}")
|
||||
# 首次发版前(只有 Unreleased)必须放行 —— 否则每个新工程一开箱就红
|
||||
(Path(d) / "new.md").write_text(
|
||||
"# 变更记录\n\n## [Unreleased]\n\n### 改了什么\n\n- 还没发布\n", encoding="utf-8")
|
||||
if check(Path(d) / "new.md"):
|
||||
problems.append(f"只有 Unreleased 的新工程竟然报红:{check(Path(d) / 'new.md')}")
|
||||
(Path(d) / "none.md").write_text("# 变更记录\n\n(什么都没有)\n", encoding="utf-8")
|
||||
if not check(Path(d) / "none.md"):
|
||||
got = check(g, root=dd)
|
||||
if got:
|
||||
problems.append(f"合规夹具竟然报红:{got}")
|
||||
# 开发期:只有 Unreleased(还没有任何正式版本)必须放行
|
||||
(dd / "new.md").write_text("# Changelog\n\n## [Unreleased]\n\n### Added\n\n- 还没发布\n",
|
||||
encoding="utf-8")
|
||||
if check(dd / "new.md", root=dd):
|
||||
problems.append(f"只有 Unreleased 的新工程竟然报红:{check(dd / 'new.md', root=dd)}")
|
||||
# 完全没有版本记录 ⇒ 红
|
||||
(dd / "none.md").write_text("# Changelog\n\n(什么都没有)\n", encoding="utf-8")
|
||||
if not check(dd / "none.md", root=dd):
|
||||
problems.append("完全没有版本记录的文件竟然没报红")
|
||||
joined = " / ".join(check(b))
|
||||
for want in ["缺少日期", f"缺少小节 `{SEC_COMPAT}`", f"缺少小节 `{SEC_KNOWN}`", "严格递减"]:
|
||||
if want not in joined:
|
||||
problems.append(f"违规夹具没有报出 `{want}` —— 这条判据是死的")
|
||||
# 破坏性变化写在非 Changed 分类里 ⇒ 也要红(位置固定)
|
||||
(dd / "wrongsec.md").write_text(
|
||||
"# Changelog\n\n## [Unreleased]\n\n### Added\n\n- **破坏性**:删了接口(见 "
|
||||
"[说明](docs/RELEASE_RUNBOOK.md))\n", encoding="utf-8")
|
||||
if not check(dd / "wrongsec.md", root=dd):
|
||||
problems.append("破坏性变化写在 Added 里竟然没报红(位置必须固定在 Changed)")
|
||||
# 末尾跟一个"不带版本号的二级标题":上一节是空的就必须判红(不能被那段文字兜成"有内容")
|
||||
(dd / "tailh2.md").write_text(
|
||||
"# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n### Security\n\n"
|
||||
"## 能力基线(未发布)\n\n- 这段文字不属于 Security 节\n", encoding="utf-8")
|
||||
tail_got = " / ".join(check(dd / "tailh2.md", root=dd))
|
||||
if "是空栏目" not in tail_got:
|
||||
problems.append(f"空的最后一节被后面的二级标题兜成了『有内容』:{tail_got[:200]}")
|
||||
|
||||
# 前导零版本号必须报(B 夹具里已含,单独再钉一次)
|
||||
(Path(d) / "zero.md").write_text(
|
||||
"# 变更记录\n\n## [Unreleased]\n\n## [01.0.0] - 2026-01-01\n\n" + "".join(
|
||||
f"{s}\n\n- x\n\n" for s in REQUIRED_SECS), encoding="utf-8")
|
||||
if not any("不合法" in p for p in check(Path(d) / "zero.md")):
|
||||
problems.append("带前导零的版本号没有判红")
|
||||
|
||||
# 空节必须报
|
||||
(Path(d) / "empty.md").write_text(
|
||||
"# 变更记录\n\n## [Unreleased]\n\n## [0.1.0] - 2026-01-01\n\n"
|
||||
+ f"{SEC_CHANGE}\n\n- x\n\n{SEC_COMPAT}\n\n- 无\n\n{SEC_ACCEPT}\n\n- 无\n\n{SEC_KNOWN}\n\n",
|
||||
# 围栏代码块里的 `###`/`##` 不算标题
|
||||
(dd / "fence.md").write_text(
|
||||
"# Changelog\n\n## [Unreleased]\n\n### Added\n\n- 命令示例:\n\n```bash\n## [9.9.9] - 1999-01-01\n### Changed\n```\n",
|
||||
encoding="utf-8")
|
||||
if not any("下面是空的" in p for p in check(Path(d) / "empty.md")):
|
||||
problems.append("空的『已知问题』节没有判红")
|
||||
if check(dd / "fence.md", root=dd):
|
||||
problems.append(f"围栏里的假标题被当成了真结构:{check(dd / 'fence.md', root=dd)}")
|
||||
|
||||
joined = " / ".join(check(b, root=dd))
|
||||
for want in ["日期不是合法日期", "不属于允许的四类", "是空栏目", "版本号重复",
|
||||
"严格递减", "版本号不合法", "破坏性变化不合规"]:
|
||||
if want not in joined:
|
||||
problems.append(f"违规夹具没有报出 `{want}` —— 这条判据是死的(实际:{joined[:200]})")
|
||||
for want in ["链接的发布文档不存在", "没有链接", "位置固定"]:
|
||||
if want not in joined:
|
||||
problems.append(f"破坏性变化的判据没有报出 `{want}`(实际:{joined[:300]})")
|
||||
|
||||
# ── tag 一致性(造真仓库)──
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
repo_ok = Path(d) / "ok"
|
||||
repo_ok.mkdir()
|
||||
_seed_repo(repo_ok, GOOD, "v0.2.0", annotated=True, tag_ver="0.2.0")
|
||||
globals()["REMOTE_TAGS"] = repo_ok / "absent" # 走本地 ls-remote 失败 → tag -l 分支
|
||||
globals()["ROOT"] = repo_ok
|
||||
try:
|
||||
if check_tags(repo_ok / "CHANGELOG.md"):
|
||||
problems.append(f"tag 一致时竟然报红:{check_tags(repo_ok / 'CHANGELOG.md')}")
|
||||
dd = Path(d)
|
||||
ok = dd / "ok"
|
||||
ok.mkdir()
|
||||
_seed_repo(ok, GOOD, "v0.2.0", annotated=True)
|
||||
if check_tags(ok / "CHANGELOG.md", cwd=ok):
|
||||
problems.append(f"tag 一致时竟然报红:{check_tags(ok / 'CHANGELOG.md', cwd=ok)}")
|
||||
|
||||
repo_bad = Path(d) / "bad"
|
||||
repo_bad.mkdir()
|
||||
_seed_repo(repo_bad, GOOD, "v9.9.9", annotated=True, tag_ver="9.9.9")
|
||||
globals()["ROOT"] = repo_bad
|
||||
if not any("!=" in p for p in check_tags(repo_bad / "CHANGELOG.md")):
|
||||
problems.append("tag 版本与提交内版本不一致时没有判红")
|
||||
bad = dd / "bad"
|
||||
bad.mkdir()
|
||||
_seed_repo(bad, GOOD, "v9.9.9", annotated=True)
|
||||
if not any("!=" in p for p in check_tags(bad / "CHANGELOG.md", cwd=bad)):
|
||||
problems.append("tag 版本与提交内版本不一致时没有判红")
|
||||
|
||||
repo_light = Path(d) / "light"
|
||||
repo_light.mkdir()
|
||||
_seed_repo(repo_light, GOOD, "v0.2.0", annotated=False, tag_ver="0.2.0")
|
||||
globals()["ROOT"] = repo_light
|
||||
if not any("轻量 tag" in p for p in check_tags(repo_light / "CHANGELOG.md")):
|
||||
problems.append("轻量 tag 没有判红")
|
||||
light = dd / "light"
|
||||
light.mkdir()
|
||||
_seed_repo(light, GOOD, "v0.2.0", annotated=False)
|
||||
if not any("轻量 tag" in p for p in check_tags(light / "CHANGELOG.md", cwd=light)):
|
||||
problems.append("轻量 tag 没有判红")
|
||||
|
||||
repo_none = Path(d) / "none"
|
||||
repo_none.mkdir()
|
||||
_seed_repo(repo_none, GOOD, "v0.2.0", annotated=True, tag_ver="0.2.0")
|
||||
subprocess.run(["git", "tag", "-d", "v0.2.0"], cwd=repo_none, capture_output=True)
|
||||
globals()["ROOT"] = repo_none
|
||||
if check_tags(repo_none / "CHANGELOG.md"):
|
||||
problems.append("没有 tag 时竟然报红(开发版本不打 tag 是正常的)")
|
||||
finally:
|
||||
globals()["REMOTE_TAGS"] = REMOTE_TAGS
|
||||
globals()["ROOT"] = ROOT
|
||||
_ = os
|
||||
none = dd / "none"
|
||||
none.mkdir()
|
||||
_seed_repo(none, GOOD, "v0.2.0", annotated=True)
|
||||
subprocess.run(["git", "tag", "-d", "v0.2.0"], cwd=none, capture_output=True)
|
||||
if check_tags(none / "CHANGELOG.md", cwd=none):
|
||||
problems.append("没有 tag 时竟然报红(开发版本不打 tag 是正常的)")
|
||||
|
||||
# 非正式 tag(如本地 `x`)不当作发布版本、不判红
|
||||
note = dd / "note"
|
||||
note.mkdir()
|
||||
_seed_repo(note, GOOD, "x", annotated=False)
|
||||
if check_tags(note / "CHANGELOG.md", cwd=note):
|
||||
problems.append("非正式 tag 竟然报红(它不该被当成发布版本)")
|
||||
|
||||
# 没核对远端 ⇒ 本地模式只提示;`--require-remote` 模式必须判红
|
||||
local = dd / "local"
|
||||
local.mkdir()
|
||||
_seed_repo(local, GOOD, "v0.2.0", annotated=True)
|
||||
subprocess.run(["git", "remote", "add", "origin", str(dd / "nowhere.git")],
|
||||
cwd=local, capture_output=True)
|
||||
if check_tags(local / "CHANGELOG.md", cwd=local):
|
||||
problems.append("本地模式(未核对远端)不该判红,只该提示")
|
||||
if not any("没核对远端" in p for p in
|
||||
check_tags(local / "CHANGELOG.md", cwd=local, require_remote=True)):
|
||||
problems.append("--require-remote 时没核对远端竟然没判红(不许声称校验成功)")
|
||||
|
||||
if problems:
|
||||
print("❌ 反向自检失败:", file=sys.stderr)
|
||||
for p in problems:
|
||||
print(f" · {p}", file=sys.stderr)
|
||||
return 1
|
||||
print("✅ 反向自检通过:合规夹具全绿;缺日期/缺小节/前导零/空节/顺序倒置/tag 版本不符/"
|
||||
"轻量 tag/无记录逐条报红;无 tag 与「只有 Unreleased」不报红")
|
||||
print("✅ 反向自检通过:合规夹具全绿;非法日期/自创栏目/空栏目/重复/顺序倒置/前导零/"
|
||||
"破坏性无链接或链接不存在/轻量 tag/tag 版本不符/未核对远端(--require-remote) 逐条报红;"
|
||||
"只有 Unreleased、无 tag、非正式 tag 不报红")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -343,20 +469,22 @@ def main(argv: list[str]) -> int:
|
||||
return self_test()
|
||||
target = next((a for a in argv if not a.startswith("-")), None)
|
||||
path = Path(target) if target else None
|
||||
require_remote = "--require-remote" in argv or os.environ.get("CI") == "true"
|
||||
problems = check(path)
|
||||
if "--tags" in argv or "--tag-check" in argv:
|
||||
problems += check_tags(path)
|
||||
problems += check_tags(path, require_remote=require_remote)
|
||||
if problems:
|
||||
print("❌ CHANGELOG / 版本记录不合规:", 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 - …(看不清就写“无”,但不能空着)\n\n"
|
||||
" ### 怎么验收\n 1. 命令 → 期望现象\n\n ### 已知问题\n - …\n",
|
||||
file=sys.stderr)
|
||||
print("\n小节只认四类(分类可省略,但不要空栏目):\n"
|
||||
" ### Added / ### Changed / ### Fixed / ### Security\n"
|
||||
"破坏性变化写在 `### Changed` 里,以 `**破坏性**` 开头并链接发布文档:\n"
|
||||
" - **破坏性**:接口 `/old` 下线,升级步骤见 [发布说明](docs/RELEASE_RUNBOOK.md)\n"
|
||||
"正式版本条目:`## [x.y.z] - YYYY-MM-DD`\n", file=sys.stderr)
|
||||
return 1
|
||||
print("✅ CHANGELOG / 版本记录合规" + ("(含 tag 一致性)" if ("--tags" in argv or "--tag-check" in argv) else ""))
|
||||
print("✅ CHANGELOG / 版本记录合规"
|
||||
+ ("(含 tag 一致性)" if ("--tags" in argv or "--tag-check" in argv) else ""))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+188
-41
@@ -74,36 +74,77 @@ cp CHANGELOG.md /tmp/kg-changelog.bak
|
||||
python3 - <<'PY'
|
||||
import pathlib
|
||||
p = pathlib.Path('CHANGELOG.md')
|
||||
t = p.read_text(encoding='utf-8')
|
||||
# 造一个"没有日期"的已发布版本标题(结构自检必须抓住)
|
||||
old = '## [0.1.0] - 2026-10-10'
|
||||
assert t.count(old) == 1, f'夹具锚点不唯一({t.count(old)} 处)—— 别静默跳过'
|
||||
p.write_text(t.replace(old, '## [9.9.9]', 1), encoding='utf-8')
|
||||
t = p.read_text(encoding='utf-8').rstrip('\n')
|
||||
# 追加一条"没有日期"的已发布版本标题(结构自检必须抓住)
|
||||
p.write_text(t + '\n\n## [9.9.9]\n\n### Added\n\n- 没有日期的正式版本条目\n', encoding='utf-8')
|
||||
PY
|
||||
expect 1 "变更记录里的已发布版本缺日期 ⇒ contracts 红" -- "$PY" scripts/ci_checks.py --only contracts
|
||||
"$PY" scripts/ci_checks.py --only contracts > /tmp/kg-nodate.log 2>&1; rc=$?
|
||||
if [ "$rc" -ne 0 ] && grep -q '缺少日期' /tmp/kg-nodate.log; then
|
||||
ok "变更记录里的已发布版本缺日期 ⇒ contracts 红(原因就是缺日期)"
|
||||
else
|
||||
no "已发布版本缺日期没有判红,或红的原因不是缺日期(rc=$rc)"; tail -8 /tmp/kg-nodate.log | sed 's/^/ /'
|
||||
fi
|
||||
cp /tmp/kg-changelog.bak CHANGELOG.md
|
||||
expect 0 "还原后 contracts 恢复绿" -- "$PY" scripts/ci_checks.py --only contracts
|
||||
|
||||
# 变异测试:把**契约那一处**的注入目标路径写错 ⇒ 版本用例会 SKIP ⇒ 契约必须红(跳过不算通过)
|
||||
# ⚠️ 必须只改契约那一处:ci_checks.py 里还有一处同名的注入是给 `build-go` 用的,
|
||||
# 改错地方就会出现"夹具没生效、但看起来通过了"的假结论(这一版夹具第一稿就是这么错的)。
|
||||
# 构建身份:本仓还没有正式发布过 —— 也必须能产出**开发**身份(不退化成"上一正式版本号")
|
||||
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'
|
||||
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)
|
||||
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']}")
|
||||
raise SystemExit(0 if okv else 1)
|
||||
PY
|
||||
pycheck "没有已发布版本时不许声明发布构建(--identity --release ⇒ rc=2)" <<'PY'
|
||||
import subprocess
|
||||
p = subprocess.run(["python3", "scripts/version.py", "--identity", "--release"], capture_output=True, text=True)
|
||||
okv = p.returncode == 2 and "发布构建" in (p.stderr or "")
|
||||
print(("✅ " if okv else "❌ ") + f"退出码 {p.returncode}|{(p.stderr or '').strip().splitlines()[:1]}")
|
||||
raise SystemExit(0 if okv else 1)
|
||||
PY
|
||||
|
||||
# 变异测试①:把契约那一处的注入**整个拿掉** ⇒ 版本用例会 SKIP ⇒ 契约必须红(跳过不算通过)
|
||||
cp scripts/ci_checks.py /tmp/kg-cichecks.bak
|
||||
python3 - <<'PY'
|
||||
import pathlib
|
||||
p = pathlib.Path('scripts/ci_checks.py')
|
||||
t = p.read_text(encoding='utf-8')
|
||||
needle = '-ldflags=-X knowledge-governance/internal/version.Number={version}'
|
||||
assert t.count(needle) == 1, f'契约注入点不唯一({t.count(needle)} 处),夹具失效'
|
||||
p.write_text(t.replace(needle, '-ldflags=-X knowledge-governance/internal/version.Nope={version}', 1),
|
||||
needle = 'rc1, text = run(ldflags_for(ident))'
|
||||
assert t.count(needle) == 1, f'契约注入锚点不唯一({t.count(needle)} 处),夹具失效'
|
||||
p.write_text(t.replace(needle, 'rc1, text = run("-X knowledge-governance/internal/version.Nope=x")', 1),
|
||||
encoding='utf-8')
|
||||
PY
|
||||
"$PY" scripts/ci_checks.py --only contracts > /tmp/kg-skip.log 2>&1; rc=$?
|
||||
if [ "$rc" -ne 0 ] && grep -q 'SKIP' /tmp/kg-skip.log; then
|
||||
ok "注入路径写错 ⇒ 版本用例被 SKIP ⇒ contracts 红(确认走的是 SKIP 分支)"
|
||||
ok "注入缺失 ⇒ 版本用例被 SKIP ⇒ contracts 红(确认走的是 SKIP 分支)"
|
||||
else
|
||||
no "注入路径写错没有判红,或没走到 SKIP 分支(rc=$rc)"; tail -8 /tmp/kg-skip.log | sed 's/^/ /'
|
||||
no "注入缺失没有判红,或没走到 SKIP 分支(rc=$rc)"; tail -8 /tmp/kg-skip.log | sed 's/^/ /'
|
||||
fi
|
||||
cp /tmp/kg-cichecks.bak scripts/ci_checks.py
|
||||
|
||||
# 变异测试②:把"自称正式版 ⇒ 必须红"那条负例阉割成开发构建 ⇒ contracts 必须红(证明负例不是死的)
|
||||
python3 - <<'PY'
|
||||
import pathlib
|
||||
p = pathlib.Path('scripts/ci_checks.py')
|
||||
t = p.read_text(encoding='utf-8')
|
||||
needle = 'rc2, _ = run(ldflags_for(ident, build_kind="release", release_version=bogus))'
|
||||
assert t.count(needle) == 1, f'负例锚点不唯一({t.count(needle)} 处),夹具失效'
|
||||
p.write_text(t.replace(needle, 'rc2, _ = run(ldflags_for(ident, build_kind="development"))', 1),
|
||||
encoding='utf-8')
|
||||
PY
|
||||
expect 1 "负例被阉割(不再自称正式版)⇒ contracts 红(说明负例真的在起作用)" -- "$PY" scripts/ci_checks.py --only contracts
|
||||
cp /tmp/kg-cichecks.bak scripts/ci_checks.py
|
||||
expect 0 "还原后 contracts 恢复绿" -- "$PY" scripts/ci_checks.py --only contracts
|
||||
rm -f /tmp/kg-changelog.bak
|
||||
|
||||
say "4. 依赖锁与例外文件"
|
||||
mv web/package-lock.json /tmp/kg-lock.bak
|
||||
@@ -394,46 +435,141 @@ rm -rf "$PYF"
|
||||
|
||||
say "22. 版本记录:CHANGELOG 结构与正式 tag 一致性"
|
||||
CLK="$PY scripts/check_changelog.py"
|
||||
# ① 正例:本仓现在的 CHANGELOG 必须合规(四节齐备,且没有 tag 也不红)
|
||||
expect 0 "CHANGELOG 结构合规(含四节)" -- $CLK
|
||||
# ① 正例:本仓现在的 CHANGELOG 必须合规;没有正式 tag 也不判红
|
||||
expect 0 "CHANGELOG 结构合规(四类小节、无空栏目)" -- $CLK
|
||||
expect 0 "没有正式 tag ⇒ 不判红(开发版本不打 tag 是正常的)" -- $CLK --tags
|
||||
# ② 反向自检:缺节/前导零/空节/顺序倒置/轻量 tag/tag 版本不符 逐条会红
|
||||
# ② 检查器自检:非法日期/自创栏目/空栏目/重复/顺序倒置/前导零/破坏性无链接/轻量 tag/未核对远端 逐条会红
|
||||
if $PY scripts/check_changelog.py --self-test >/tmp/kg-cl-self.log 2>&1; then
|
||||
ok "CHANGELOG 检查的 --self-test 全过(夹具里逐条证明会红)"
|
||||
else
|
||||
no "CHANGELOG 检查自检失败"; tail -6 /tmp/kg-cl-self.log | sed 's/^/ /'
|
||||
fi
|
||||
# ③ 缺 『已知问题』节 ⇒ 必须红(真改文件,再恢复)
|
||||
# ③ 真改本仓 CHANGELOG(五种形状各判一次,改完立刻恢复)
|
||||
cp CHANGELOG.md /tmp/kg-changelog.bak
|
||||
$PY - <<'PY'
|
||||
mut() { # mut <名字> <期望 grep> <期望退出码>;变异脚本从 stdin 进
|
||||
local name="$1" want="$2" want_rc="$3"
|
||||
if ! "$PY" - >/tmp/kg-mut-py.log 2>&1; then
|
||||
no "$name:变异脚本失败(锚点不在了?)"; tail -3 /tmp/kg-mut-py.log | sed 's/^/ /'
|
||||
cp /tmp/kg-changelog.bak CHANGELOG.md; return
|
||||
fi
|
||||
$CLK >/tmp/kg-cl-mut.log 2>&1; local rc=$?
|
||||
if [ "$rc" = "$want_rc" ] && grep -q "$want" /tmp/kg-cl-mut.log; then ok "$name"
|
||||
else no "$name(rc=$rc,期望 $want_rc 且含 [$want])"; tail -6 /tmp/kg-cl-mut.log | sed 's/^/ /'; fi
|
||||
cp /tmp/kg-changelog.bak CHANGELOG.md
|
||||
}
|
||||
mut "自创栏目(### 已知问题)⇒ 红" "不属于允许的四类" 1 <<'PY'
|
||||
import pathlib, re
|
||||
p = pathlib.Path("CHANGELOG.md"); t = p.read_text(encoding="utf-8")
|
||||
i = t.index("### 已知问题"); j = t.index("\n## ", i) if "\n## " in t[i:] else len(t)
|
||||
p.write_text(t[:i] + t[j:], encoding="utf-8")
|
||||
# ⚠️ 锚点必须是**行首真标题**:顶部说明里也出现过 "`### Added`" 这类行内引用,
|
||||
# 用子串替换会改到说明文字(看起来"变异了"、结构没动)——夹具第一稿就这么假通过过。
|
||||
assert len(re.findall(r"^### Fixed$", t, re.M)) == 1, "行首真标题 ### Fixed 不唯一"
|
||||
t2 = re.sub(r"^### Fixed$", "### 已知问题", t, count=1, flags=re.M)
|
||||
assert t2 != t, "没改成"
|
||||
p.write_text(t2, encoding="utf-8")
|
||||
PY
|
||||
expect 1 "删掉『已知问题』节 ⇒ version 检查红" -- $CLK
|
||||
cp /tmp/kg-changelog.bak CHANGELOG.md
|
||||
expect 0 "恢复后回到绿" -- $CLK
|
||||
# ④ 版本号带前导零 ⇒ 必须红
|
||||
$PY - <<'PY'
|
||||
import pathlib
|
||||
mut "空栏目(清空 Security 内容)⇒ 红" "是空栏目" 1 <<'PY'
|
||||
import pathlib, re
|
||||
p = pathlib.Path("CHANGELOG.md"); t = p.read_text(encoding="utf-8")
|
||||
p.write_text(t.replace("## [0.1.0]", "## [01.1.0]", 1), encoding="utf-8")
|
||||
assert len(re.findall(r"^### Security$", t, re.M)) == 1, "行首真标题 ### Security 不唯一"
|
||||
t2 = re.sub(r"(^### Security\n(?:\n)?)(?:- [^\n]*\n)+", r"\1", t, count=1, flags=re.M)
|
||||
assert t2 != t, "没清掉 Security 的内容"
|
||||
p.write_text(t2, encoding="utf-8")
|
||||
PY
|
||||
mut "破坏性变化没带发布文档链接 ⇒ 红" "没有链接" 1 <<'PY'
|
||||
import pathlib, re
|
||||
p = pathlib.Path("CHANGELOG.md"); t = p.read_text(encoding="utf-8")
|
||||
assert len(re.findall(r"^### Changed$", t, re.M)) == 1, "行首真标题 ### Changed 不唯一"
|
||||
t2 = re.sub(r"^### Changed$", "### Changed\n\n- **破坏性**:某接口下线,升级步骤见文档(故意不写链接)",
|
||||
t, count=1, flags=re.M)
|
||||
assert t2 != t, "没改成"
|
||||
p.write_text(t2, encoding="utf-8")
|
||||
PY
|
||||
mut "破坏性变化链接的文档不存在 ⇒ 红" "链接的发布文档不存在" 1 <<'PY'
|
||||
import pathlib, re
|
||||
p = pathlib.Path("CHANGELOG.md"); t = p.read_text(encoding="utf-8")
|
||||
assert len(re.findall(r"^### Changed$", t, re.M)) == 1, "行首真标题 ### Changed 不唯一"
|
||||
t2 = re.sub(r"^### Changed$", "### Changed\n\n- **破坏性**:某接口下线,见 [迁移步骤](docs/NO_SUCH_DOC.md)",
|
||||
t, count=1, flags=re.M)
|
||||
assert t2 != t, "没改成"
|
||||
p.write_text(t2, encoding="utf-8")
|
||||
PY
|
||||
mut "版本号带前导零 ⇒ 红" "不合法" 1 <<'PY'
|
||||
import pathlib
|
||||
p = pathlib.Path("CHANGELOG.md"); t = p.read_text(encoding="utf-8").rstrip("\n")
|
||||
p.write_text(t + "\n\n## [01.0.0] - 2026-03-02\n\n### Fixed\n\n- x\n", encoding="utf-8")
|
||||
PY
|
||||
expect 1 "版本号带前导零 ⇒ 红" -- $CLK
|
||||
cp /tmp/kg-changelog.bak CHANGELOG.md
|
||||
# ⑤ tag 一致性:一致 ⇒ 绿;版本不符 ⇒ 红;轻量 tag ⇒ 红;删掉 ⇒ 绿
|
||||
git tag -a v0.1.0 -m "release 0.1.0" >/dev/null 2>&1
|
||||
expect 0 "注释 tag v0.1.0 与提交内版本一致 ⇒ 绿" -- $CLK --tags
|
||||
git tag -a v9.9.9 -m "release 9.9.9" >/dev/null 2>&1
|
||||
expect 1 "tag v9.9.9 与提交内版本 0.1.0 不符 ⇒ 红" -- $CLK --tags
|
||||
git tag -d v9.9.9 >/dev/null 2>&1
|
||||
git tag v0.2.0 >/dev/null 2>&1 # 轻量 tag
|
||||
expect 1 "轻量 tag(不是注释 tag)⇒ 红" -- $CLK --tags
|
||||
git tag -d v0.2.0 >/dev/null 2>&1
|
||||
git tag -d v0.1.0 >/dev/null 2>&1
|
||||
expect 0 "tag 全部删除后 ⇒ 绿(无 tag 不判红)" -- $CLK --tags
|
||||
rm -f /tmp/kg-changelog.bak
|
||||
expect 0 "本仓 CHANGELOG 恢复后仍是绿的" -- $CLK
|
||||
# ④ tag 一致性与远端核对:**只在临时仓库里造 tag**,绝不污染本仓(正本 §6.6 注)
|
||||
pycheck "tag 一致性与远端核对(临时仓库内 6 个场景)" <<'PY'
|
||||
import os, pathlib, shutil, subprocess, tempfile
|
||||
|
||||
SAMPLE = """# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- 还没发布
|
||||
|
||||
## [0.1.0] - 2026-10-10
|
||||
|
||||
### Changed
|
||||
|
||||
- **破坏性**:接口 `/old` 下线,升级步骤见 [发布说明](docs/RELEASE_RUNBOOK.md)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修了 X
|
||||
"""
|
||||
|
||||
def git(d, *args):
|
||||
return subprocess.run(["git", *args], cwd=d, capture_output=True, text=True)
|
||||
|
||||
def seed(d, changelog=SAMPLE):
|
||||
(d / "scripts").mkdir(parents=True)
|
||||
(d / "docs").mkdir()
|
||||
shutil.copy("scripts/check_changelog.py", d / "scripts/check_changelog.py")
|
||||
(d / "docs" / "RELEASE_RUNBOOK.md").write_text("# 发布说明\n", encoding="utf-8")
|
||||
(d / "CHANGELOG.md").write_text(changelog, encoding="utf-8")
|
||||
(d / "no-hooks").mkdir(exist_ok=True)
|
||||
os.environ.update({"GIT_AUTHOR_NAME": "f", "GIT_AUTHOR_EMAIL": "f@f",
|
||||
"GIT_COMMITTER_NAME": "f", "GIT_COMMITTER_EMAIL": "f@f",
|
||||
"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.hooksPath",
|
||||
"GIT_CONFIG_VALUE_0": str(d / "no-hooks")})
|
||||
git(d, "init", "-q", "-b", "main")
|
||||
git(d, "add", "-A")
|
||||
git(d, "commit", "-q", "-m", "chore: 夹具提交")
|
||||
git(d, "remote", "add", "origin", str(d / "nowhere.git")) # 没有远端 ⇒ 只能看本地 tag
|
||||
|
||||
def clk(d, *args):
|
||||
return subprocess.run(["python3", str(d / "scripts/check_changelog.py"), *args],
|
||||
cwd=d, capture_output=True, text=True)
|
||||
|
||||
cases = []
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
r = pathlib.Path(tmp) / "r"; r.mkdir(); seed(r)
|
||||
cases.append(("无正式 tag ⇒ 绿", clk(r, "--tags").returncode == 0))
|
||||
git(r, "tag", "-a", "v0.1.0", "-m", "release 0.1.0")
|
||||
cases.append(("注释 tag 与提交内版本一致 ⇒ 绿", clk(r, "--tags").returncode == 0))
|
||||
git(r, "tag", "-a", "v9.9.9", "-m", "release 9.9.9")
|
||||
cases.append(("tag 与提交内版本不符 ⇒ 红", clk(r, "--tags").returncode == 1))
|
||||
git(r, "tag", "-d", "v9.9.9")
|
||||
git(r, "tag", "v0.2.0") # 轻量 tag
|
||||
cases.append(("轻量 tag ⇒ 红", clk(r, "--tags").returncode == 1))
|
||||
git(r, "tag", "-d", "v0.2.0")
|
||||
p2 = clk(r, "--tags", "--require-remote")
|
||||
cases.append(("拿不到远端清单且 --require-remote ⇒ 红(不许声称已核验)",
|
||||
p2.returncode == 1 and "没核对远端" in (p2.stdout + p2.stderr)))
|
||||
r2 = pathlib.Path(tmp) / "r2"; r2.mkdir()
|
||||
seed(r2, SAMPLE.replace("## [Unreleased]", "## [Unreleased] - 2026-01-01"))
|
||||
cases.append(("未发布段写了日期 ⇒ 红", clk(r2, "--tags").returncode == 1))
|
||||
|
||||
bad = [n for n, okv in cases if not okv]
|
||||
for n, okv in cases:
|
||||
print(("✅ " if okv else "❌ ") + n)
|
||||
raise SystemExit(1 if bad else 0)
|
||||
PY
|
||||
|
||||
say "23. 发版预检:只读、不给可发结论时不越界"
|
||||
if $PY scripts/release_preflight.py --self-test >/tmp/kg-pf-self.log 2>&1; then
|
||||
@@ -447,6 +583,17 @@ 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 "没有版本基线时,预检把「首个版本号由人指定」列成阻塞项(不编一个版本号就发)"
|
||||
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
|
||||
ok "显式不核对远端(--no-api)⇒ 不给可发结论,且明说「未核对」(退出码 $rc)"
|
||||
else
|
||||
no "不核对远端却给了可发结论(退出码 $rc)"; tail -6 /tmp/kg-pf-noapi.log | sed 's/^/ /'
|
||||
fi
|
||||
if grep -qE "git tag -a|--data-binary|POST" /tmp/kg-pf.log; then
|
||||
no "预检输出里出现了写操作迹象"
|
||||
else
|
||||
|
||||
@@ -97,7 +97,9 @@ def block_of(text: str, heading: str) -> str:
|
||||
m = re.search(rf"^##\s*\[{re.escape(heading)}\][^\n]*$", text, re.MULTILINE)
|
||||
if not m:
|
||||
return ""
|
||||
nxt = re.search(r"^##\s*\[", text[m.end():], re.MULTILINE)
|
||||
# 下一个二级标题就停(`### x` 不算:`^##\s` 不匹配三井号)。
|
||||
# ⚠️ 不能只认 `## [`:像「## 能力基线(未发布)」这种没有方括号的二级标题会把整段吞进来。
|
||||
nxt = re.search(r"^##\s", text[m.end():], re.MULTILINE)
|
||||
end = m.end() + (nxt.start() if nxt else len(text) - m.end())
|
||||
return text[m.start():end].rstrip()
|
||||
|
||||
@@ -152,11 +154,11 @@ def main(argv: list[str]) -> int:
|
||||
|
||||
text = CHANGELOG.read_text(encoding="utf-8") if CHANGELOG.is_file() else ""
|
||||
rel = released_versions(text)
|
||||
if not rel:
|
||||
print("❌ CHANGELOG.md 里没有已发布版本 ⇒ 没有版本基线,发不了版", file=sys.stderr)
|
||||
return 2
|
||||
current = rel[0]
|
||||
candidate = args.version or bump(current, args.bump)
|
||||
current = rel[0] if rel else None
|
||||
first_release = not rel
|
||||
# 还没有正式发布过时:没有可 --bump 的基线,版本号必须由人显式指定 ——
|
||||
# 这里只给一个建议值,并把它当成阻塞项(不假装有基线)。
|
||||
candidate = args.version or (bump(current, args.bump) if current else "1.0.0")
|
||||
if not SEMVER.match(candidate):
|
||||
print(f"❌ 候选版本号不合法:{candidate!r}(要 主.次.修订 三段十进制、无前导零)", file=sys.stderr)
|
||||
return 2
|
||||
@@ -175,21 +177,31 @@ def main(argv: list[str]) -> int:
|
||||
print(f" 分支/提交 : {branch} @ {head or '(取不到 HEAD)'}"
|
||||
f"{'' if FULL_SHA.match(head) else ' ⚠️ 不是完整 40 位'}")
|
||||
print(f" 工作区 : {'干净' if not dirty_lines else f'有 {len(dirty_lines)} 处未提交 —— 发版要在干净树上看'}")
|
||||
print(f" 当前已发布 : {current}")
|
||||
print(f" 待发布版本 : {candidate}({'显式 --version' if args.version else f'--bump {args.bump} 的建议值;'
|
||||
f'可选 major/minor/patch'})")
|
||||
print(f" 当前已发布 : {current if current else '(还没有正式发布过 —— CHANGELOG 里没有已发布版本条目)'}")
|
||||
if first_release:
|
||||
print(f" 待发布版本 : {candidate}(**建议值**:本仓还没有正式版本基线,"
|
||||
f"首个正式版本号由你指定,用 `--version X.Y.Z` 覆盖)")
|
||||
else:
|
||||
print(f" 待发布版本 : {candidate}({'显式 --version' if args.version else f'--bump {args.bump} 的建议值;'
|
||||
f'可选 major/minor/patch'})")
|
||||
|
||||
# CHANGELOG 内容
|
||||
unreleased = block_of(text, "Unreleased")
|
||||
print("\n CHANGELOG(Unreleased 原样打印):")
|
||||
print(" " + (unreleased.replace("\n", "\n ")[:1500] if unreleased.strip() else "(没有 Unreleased 段:"
|
||||
"正式发版前要把这次改动写进去)"))
|
||||
known = section(block_of(text, current), "### 已知问题") if current in rel else ""
|
||||
print(f"\n 当前版本的『已知问题』: {known.splitlines()[0] if known else '(没有写)'}")
|
||||
breaking = [l.strip() for l in text.splitlines()
|
||||
if l.strip().startswith(("- **破坏性**", "* **破坏性**"))]
|
||||
print(f"\n 破坏性变化 : {len(breaking)} 条(写在 `### Changed` 里、必须带发布文档链接,"
|
||||
f"写清升级/迁移与回退)")
|
||||
for b in breaking[:5]:
|
||||
print(f" · {b[:150]}")
|
||||
print(" 遗留风险 : 见 docs/BACKLOG.md 与 Unreleased 里写明的未验证项"
|
||||
"(预检不替你判断它们的可接受性)")
|
||||
|
||||
# 检查器(结构与 tag 一致性)
|
||||
chk = subprocess.run([sys.executable or "python3", str(ROOT / "scripts" / "check_changelog.py"), "--tags"],
|
||||
cwd=ROOT, capture_output=True, text=True)
|
||||
chk = subprocess.run([sys.executable or "python3", str(ROOT / "scripts" / "check_changelog.py"),
|
||||
"--tags", "--require-remote"], cwd=ROOT, capture_output=True, text=True)
|
||||
checker_ok = chk.returncode == 0
|
||||
print(f"\n CHANGELOG/版本记录检查: {'✅ 通过' if checker_ok else '❌ 不通过 —— 先修它再发版'}")
|
||||
if not checker_ok:
|
||||
@@ -198,6 +210,9 @@ def main(argv: list[str]) -> int:
|
||||
# 远端 tag / Release / CI 证据
|
||||
tag = f"v{candidate}"
|
||||
blockers: list[str] = []
|
||||
if first_release:
|
||||
blockers.append(f"还没有已发布版本:首个正式版本号必须由人显式指定(--version X.Y.Z);"
|
||||
f"{candidate} 只是建议值")
|
||||
if dirty_lines:
|
||||
blockers.append(f"工作区有 {len(dirty_lines)} 处未提交")
|
||||
if not FULL_SHA.match(head):
|
||||
@@ -216,8 +231,13 @@ def main(argv: list[str]) -> int:
|
||||
remote_tags = [l.split("refs/tags/")[-1] for l in tags_out.splitlines() if "refs/tags/" in l]
|
||||
if f"{tag}^{{}}" in remote_tags:
|
||||
remote_tags.remove(f"{tag}^{{}}")
|
||||
print(f"\n 远端 tag : {tag} {'已存在 ⛔ 不能覆盖已发布的 tag' if tag in remote_tags else '不存在(可发)'}"
|
||||
f"|已存在的正式 tag: {[t for t in remote_tags if re.match(r'^v\\d', t)] or '(无)'}")
|
||||
if rc != 0 or args.no_api:
|
||||
why = "显式 --no-api" if args.no_api else "`git ls-remote --tags origin` 失败"
|
||||
print(f"\n 远端 tag : **未核对**({why})—— 不能声称 tag 已核验")
|
||||
blockers.append("没有核对远端 tag 信息(查不到远端)—— 未核验不等于可以发")
|
||||
else:
|
||||
print(f"\n 远端 tag : {tag} {'已存在 ⛔ 不能覆盖已发布的 tag' if tag in remote_tags else '不存在(可发)'}"
|
||||
f"|已存在的正式 tag: {[t for t in remote_tags if re.match(r'^v\\d', t)] or '(无)'}")
|
||||
|
||||
run_line = "(未查询)"
|
||||
ctx_line = "(未查询)"
|
||||
@@ -251,7 +271,7 @@ def main(argv: list[str]) -> int:
|
||||
blockers.append("没有核对远端 Release/CI 状态(缺凭据)")
|
||||
print(f" CI 状态 : {ctx_line}")
|
||||
print(f" 最近运行 : {run_line}")
|
||||
print(" 遗留风险/未验证: 见 CHANGELOG 的『已知问题』与本次 Unreleased 里写明的未验证项"
|
||||
print(" 遗留风险/未验证: 见 docs/BACKLOG.md 与本次 Unreleased 里写明的未验证项"
|
||||
"(预检不替你判断它们的可接受性)")
|
||||
|
||||
print("\n── 结论 ──")
|
||||
|
||||
Reference in New Issue
Block a user