# 已知陷阱(全文) > 这份文件是 `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:浅克隆里 **`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 ` 取任意提交(服务端默认不允许) **症状**:分支 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/..`;取不到基准 ⇒ **明确失败**,不许退化成整部历史或空集。 **修法**:`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` 分支)。 教训:负例要**照真实事件负载的形状**造,不能只造自己想的形状。