按项目方给的规则重做 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。
20 KiB
已知陷阱(全文)
这份文件是
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:浅克隆里 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 --tags / --unshallow;否则就是这条。
修法(本仓采用):
- 版本号的唯一来源是仓库里的
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 不会自动检出代码
症状:工作流第一步就报找不到文件 / 空工作区。
根因:本仓的 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 —— 时红时绿,且方向完全错。
trap-8:gofmt -l 列出文件时退出码仍是 0(只看退出码=假绿)
症状:门禁里写着 gofmt -l .(或 step gofmt -l . || fail=1),格式乱掉的文件明明被列出来了,
这一步却通过。整条门禁全绿,而仓库里躺着一堆没格式化的文件。
根因:gofmt -l 的正常行为就是"列出不合规的文件并返回 0" ——
它的退出码表达的是"我跑完了",不是"都合规"。用退出码当判据,等于没判。
判据:任何"输出即问题"的工具(gofmt -l、prettier --list-different、git status --porcelain)
都必须判输出是否为空,不能判退出码。
修法:[ -z "$(gofmt -l <文件...>)" ],或者像本仓 scripts/check_format.py 那样解析输出;
并且给它一条故意弄坏的夹具证明它真的会红(否则"永远返回 0 的校验器"会一路绿灯)。
反例:step gofmt -l . || fail=1 —— 生成器里就是这么写的,一直没人发现。
trap-9:tar -C dir . 产生 . 根成员 ⇒ 预览 404
症状:打包与上传都成功、通知也发了,点开预览链接却是 404 或报错。
根因:tar -czf x.tgz -C dir . 会把 .(以及 ./index.html)写进成员名。
严格的解包器要么拒绝整包,要么解出一层多余的 .,于是 index.html 不在预期的位置上。
判据:tar -tzf <包> 的第一行若是 . 或 ./…,即是这条。
修法:先 cd 进产物目录,再用通配展开成员名(( cd "$WEB" && { shopt -s dotglob; tar -czf "$TAR" *; } )),
并在发之前断言包里真的有 index.html(tar -tzf "$TAR" | grep -qx 'index.html')。
反例:只看"打包成功 / 上传 HTTP 200"就当预览发好了 —— 那两步都不会因为包结构不对而失败。
trap-10:依赖清单被静默改写
症状:一次 CI 通过之后,go.mod / go.sum / package-lock.json 与提交里的不一样了;
本地跑一遍还多了/少了几个依赖,没人改过它们。
根因:两个"顺手"的默认行为 —— GOFLAGS=-mod=mod(构建时自动补全/更新 go.mod)、
以及缺锁文件时回落到 npm install(按 package.json 重算整棵依赖树并重写锁文件)。
两者都让"依赖清单"变成运行时产物,而它本该是人 review 过的契约。
判据:夹具 = 门禁开始前后对这几个文件取 sha256;任何一步改写了它们 ⇒ 红。
修法:GOFLAGS=-mod=readonly;缺锁直接失败(不接受无约束安装);
npm ci(按锁装、锁与 package.json 不一致就报错)。见门禁的 deps-install 与 manifests-locked 两项。
反例:if [ -f package-lock.json ]; then npm ci; else npm install; fi —— 看起来"更健壮",
实际是给漂移开了后门。
trap-11:汇总把「未运行 / 被跳过」当「通过」
症状:某个必需检查压根没跑(失败即停的后果、或运行时被 if: 跳过),
汇总却报"全绿";事后看报告,那一项根本不在报告里,谁都没发现。
根因:汇总逻辑只遍历"跑过的项"。没跑的项不出现 ⇒ 集合变小 ⇒ 全绿。
另一种写法是 skipped 与 success 一起放行。
判据:给汇总喂一个"必需项缺失 / 必需项 skipped"的夹具,它必须非绿。
修法:清单里声明必需项集合,汇总按清单(不是按结果数组)逐项核对;
缺项记 not_run 并判红;aggregate() 只认 success。
不适用要在清单里声明(本仓的 required_in),不能在运行时悄悄 skip。
反例:status = "failure" if any(x == "failure" for x in results) else "success"。
trap-12:事件 SHA ≠ 检出 SHA
症状:CI 里跑的是"分支当前 HEAD",而这次运行的事件指向的是另一个提交。 表现是"我刚推的那一步好像没跑",或者更糟 —— 拿新代码的结论去解释旧代码的产物。
根因:工作流写 git fetch --depth 50 origin "$BR" && git checkout -B "$BR" FETCH_HEAD,
取的是当时的分支 HEAD。只要在事件产生之后又有人推了一次(或重跑旧运行),两者就分叉。
判据:git rev-parse HEAD 必须等于 ${GITHUB_SHA}(完整 SHA,不是短 sha)。
不一致 ⇒ 红。
修法:检出后立刻核对;不一致时改按精确 SHA 取(git fetch --depth 50 origin "$EVENT_SHA"),
再取不到就失败。门禁的 identity 项会再核对一次(两道,故意的)。
反例:报告里只写"分支 main 的构建成功" —— 这个结论没有对应的提交。
trap-13:密钥扫描规则过宽 ⇒ 永远的假红
症状:接入密钥扫描后立刻红,命中全是正常代码:postgres://{u.quote(user)}:{u.quote(password)}@host/db
(模板表达式)、postgres://governance:***@postgres/governance(脱敏占位)、
X-CI-Token: ${BRIDGE_TOKEN}。
根因:规则用 [^@\s]{6,} 这种"很宽"的字符类去抓口令段,于是把变量插值、脱敏星号、
模板表达式全部当成密钥。假红一旦出现,人就会把整个检查注释掉 —— 那比没有检查更糟。
判据:扫描器必须自带双向夹具:合成的真密钥要命中,良性对照要零误报, 而良性对照里要钉住踩过的误报形状(防回归)。
修法:只收"干净字符"的口令段([A-Za-z0-9+/=_\-]{6,}),并对命中值做占位符判定
(${...}、<...>、***、changeme…)。见 scripts/check_secrets.py 的 RULES 与 --self-test。
反例:为了让门禁变绿去放宽/注释规则 —— 那是降低规则换绿灯,规范 §4.2 明确禁止。
trap-14|"这个语言我没写检查" = 假报成功(通用模板的 Python 门禁空跑)
症状:通用模板套到一个 Python 项目上,deps-install / format / static / build / tests 五项全绿,
但一个 Python 检查都没跑 —— 函数体只打印一句话就 return SUCCESS。
根因:模板最初只为 Go+Node 写实现,Python 分支留了"占位返回成功"。占位返回成功不是"还没做", 是假报成功:它会让人以为这个项目被检查过。审查是靠"模拟一个 Python 项目,看那五项是不是真的执行了" 才发现的 —— 只看 YAML/看代码是看不出来的。
判据:每一门识别到的语言都必须真的执行;工具缺失、依赖没按锁装、输出解析不了 ⇒ 失败。
修法:uv sync --frozen / uv run ruff format --check / compileall / uv run pytest;
need() 找不到工具就抛错(转成 FAILURE)。前端同理:测试进程退出码非零必须红
(报告写 pass 不算数),框架按 test-scope.json 声明,认不出来就失败。
trap-15|预览放在"报告核对"之前 = 发出去就收不回来
症状:预览步骤只读 overall_status + commit_sha 就发布;完整报告核对在后面的通知步骤。
报告缺项时,预览已经发出去了,几分钟后才报错 —— 那时 URL 可能已经被人打开/转群。
根因:把"发布"当成"通知"的一环。预览是不可撤回的对外动作,不能排在质量结论之前。
判据:发布前必须调用同一份核对逻辑(ci_report.verify()),有任何问题 ⇒ 一个 publish 请求都不发。
修法:.gitea/scripts/ci_preview.py 内部先核对再发布;scripts/ci_preview_negative_test.py
用假 bridge 数请求证明坏报告 0 次、好报告 1 次。
trap-16|用"观察到的 context"代替"要求哪些检查"
症状:合并前没指定必需 context 时,脚本拿"体检时在该仓看到过哪些状态"当必需列表。
根因:combined state 只报"报过状态的都成功",一个无关的检查成功就能让它变成 success。
于是"没跑过必需检查"的 PR 会被合进去。
判据:要求哪些检查只能来自明确策略(--require-context 或 .gitea/required-contexts.txt);
策略缺失或为空 ⇒ 拒绝合并(空不等于"没有要求")。
修法:见 scripts/adopt-existing.py 的 resolve_required_contexts() 与 --self-test-chain
(假 Gitea + 真实 merge_pr(),断言 merge 请求发没发)。同时核对 base 分支/PR 状态/head 仓库。
trap-17|remote 协议被"顺手"拼成 http
症状:注释说"保留服务端给的协议",代码却是 git remote add origin "http://${HOSTPORT}/..." ——
服务端给的是 https 时会静默降级成 http(凭据与产物走明文)。
根因:把 ${SRV#*://} 取出来的 host:port 再拼一个固定的 scheme。取 host 的动作本身就把协议丢了。
判据:remote 直接用 GITHUB_SERVER_URL 原样拼,并断言最终 scheme 与服务端一致;
未知 scheme 直接拒绝;URL 里不许带凭据。
修法:.gitea/scripts/ci_checkout.sh(--print-remote 可本地验证协议保留);
YAML 里只调用它,不再内联这套拼接。
trap-18|范围校验回退成"最近 N 条"或"接受截断的列表"
症状:范围算不出来时退化成 git log -n 5(和历史无关),或直接采信事件负载里那串 commits。
根因:事件负载里的 commits 可能是截断的(实测:声明 6 条、列表只给 1 条),
浅克隆里 before 可能根本不存在 —— 两种都会让"本次改动"少核几条,而且全绿。
判据:以准确 Git 范围为准(push 用 before..after,新分支用 after --not --remotes);
事件声明条数与 Git 范围不一致 / 空范围 / 浅克隆取不到 before / 列表提交不在检出里 ⇒ 全部拒绝;
手工运行比不出范围时只核 HEAD,但必须写明。
修法:.gitea/scripts/ci_gate.py 的 resolve_scope()(--self-test 11 个分支)+
检出脚本顺带把事件 before 取下来。
trap-19|第一步就调用仓内脚本 = 整条流水线起不来
症状:workflow 第一步写 bash .gitea/scripts/ci_checkout.sh。本地"已经有代码的目录"里跑得好好的,
真到 Runner 上返回 127(bash: .gitea/scripts/ci_checkout.sh: 没有那个文件或目录)——
因为这个 Runner 不自动检出,工作区初始是空的(trap-6),此时仓内脚本还不存在。
根因:把"检出"写成了仓内脚本,而"检出"恰恰是让仓内文件出现的那一步 —— 循环依赖。 本地验证只在有代码的目录里做,就永远看不到这个形状。
判据:第一步必须是最小 bootstrap:只依赖 runner 给的环境变量(GITHUB_SERVER_URL /
GITHUB_REPOSITORY / GITHUB_SHA / GITHUB_TOKEN),自己 git init + remote add + 取事件完整 SHA
- checkout + 断言 HEAD==事件 SHA。检出完成、脚本就位之后,才调用仓内脚本做后续校验与补取。
修法:build.yml 第一步内联 bootstrap → 然后 bash .gitea/scripts/ci_checkout.sh --post-checkout。
scripts/ci_chain_fixture.py 会把那段 run: 原样抽出来在空目录里执行(YAML 一改坏就红)。
trap-20|fetch origin <SHA> 取任意提交(服务端默认不允许)
症状:分支 HEAD 已经前进时,bootstrap 想"按精确 SHA 取",执行 git fetch origin $GITHUB_SHA
→ 服务器不允许请求未公开的对象(upload-pack 默认不开放按任意 SHA 取)。
根因:把 --depth 取分支的写法套到按 SHA 取上。这个能力在默认配置下不存在。
判据:事件 SHA 必须能从已取到的分支历史里找到;不够深就 --deepen 再找;
仍找不到才是失败(提示提高深度或等重跑)。失败要明确,不许"取不到就换个分支糊过去"。
修法:见 bootstrap 里的 git cat-file -e "${GITHUB_SHA}^{commit}" + fetch --deepen 200 分支。
trap-21|after --not --remotes 在检出后把范围排成空集
症状:新分支推送(before 为全 0)用 after --not --remotes 算范围。CI 检出时
refs/remotes/origin/<本分支> 往往已经等于 after —— 那个引用把自身排除了,范围恒为 0 条,
闸门直接失败(或更糟:被当成"没有要检查的提交"放过去)。
根因:拿"刚 fetch 下来的同名远端分支"当排除集。它是被检出的那个分支,不是基线。
判据:新分支的基准只能是远端默认分支(事件里的 repository.default_branch),
范围 = refs/remotes/origin/<default>..<after>;取不到基准 ⇒ 明确失败,不许退化成整部历史或空集。
修法:ci_gate.resolve_scope();ci_checkout.sh --post-checkout 负责把默认分支引用取下来。
--self-test 里有专门的回归用例(origin/<本分支> == after 时必须仍算出那几条提交)。
trap-22|把事件里的 total_commits 当成"声明条数"
症状:新分支推送的真实事件里 Gitea 给的是 total_commits=0、commits 列表被截断(实测 5 条),
而 Git 范围算出 12 条。严判"声明 ≠ Git 范围"⇒ 门禁在第一次真实 CI 就红了,
红的原因与代码质量无关(本地夹具用的是"声明 6 条/列表 1 条"的自造形状,覆盖不到这一种)。
根因:total_commits 在 Gitea 的语义不是"这次推送的提交条数",新分支推送时就是 0
(等于"没声明")。把它当权威声明去比对,等于用一个不存在的约束判红。
判据:Git 范围是唯一权威。total_commits > 0 时才拿来交叉核对(不一致 ⇒ 拒绝,
覆盖"声明 6 条 / 列表 1 条 / Git 2 条"那一类);<=0 或缺失 ⇒ 视为未声明,
以 Git 范围为准,并把"负载条数不可信、列表被截断"写进来源说明里。
修法:ci_gate.resolve_scope()(--self-test 里有专门的 total_commits=0 分支)。
教训:负例要照真实事件负载的形状造,不能只造自己想的形状。