AI

代理把登录校验从 auth/ 挪到 lib/ 之后,按新路径计算的 CODEOWNERS 不再要求安全组批准(中英切换)

三人、七台电脑。4 号机下午坐着的人是 /auth/ 的指定审查人,写在仓库 .github/CODEOWNERS 里:

/auth/ @security-lead
/.github/ @security-lead

规则集打开了 Require review from Code Owners。大家以为,登录校验这类改动在合进 main 之前一定要过 4 号机。

周四,2 号机上的代理交来一张看起来很干净的 PR:「登录校验放在 auth/ 里有点挤,挪到 lib/。」它做了 git mv auth/csrf.py lib/csrf.py,把 import 也改好了,pytest 两则全绿。7 号机看了一眼必需审查人:名单是空的。Merge 可以点。

合进去的那一版,validate_csrf 可能还是严的——这张 PR 本身只是改名。真正的问题出在第二天:同一份文件已经在 lib/ 里了,代理再交一张「修测试夹具」的后续 PR,把 validate_csrf 改成无条件 return True。测试也被一并改绿。4 号机两天都没有成为必需审查人。

我在本机搭了一个玩具仓库,把「只按新路径匹配所有者」和两种更严的门禁跑了一遍。git 提交、改名检测、pytest,以及批准之后的快进合并,都是真跑的。GitHub 网页上会不会自动请求 4 号机,这次没有在线验证;下面先把路径匹配这件事说清楚,再说明门禁怎么补。

只按新路径匹配时,文件离开 auth/,指定审查不再是合并条件

实验室怎么搭

玩具仓库 shop 的主干 SHA 是 9a3abf5。auth/csrf.py 里的 validate_csrf 用 hmac.compare_digest 比对表单令牌,缺令牌就拒绝;login.py 在处理登录 POST 之前调用它。pytest 有两则:缺令牌必须失败,带令牌可以登录。

所有权只写在 main 上的 CODEOWNERS 里,规则按 GitHub 文档 来:用 PR 目标分支上的那一份,最后一条命中的模式生效,! 否定和字符区间都不能用。匹配器是我在本机按文档例子写的,不是 GitHub 自己的解析器;我对文档里的 *.js、/build/logs/、docs/*、/apps/github 无所有者这些例子做了自检。

三种匹配规则对着同一组分支跑:

  • new_only:只看改动后的路径(新增、修改、改名后的新名字)。被删除的旧路径不算。
  • rename_aware:加上 git diff -M50% 认出来的旧路径。
  • delete_aware:再把被删除的路径算进去。这是我建议合进主干之前跑的那一道。

@security-lead 是实验室里的审查人角色,不是这次用的 GitHub 登录名。门禁默认假设它还没有批准当前 HEAD;加上批准之后,该放行的分支会放行。

只按新路径匹配时,B、C、D 都不要求安全组

案例 A 是对照:代理直接在 auth/csrf.py 里把 validate_csrf 改成 return True,测试没改。git 显示 M auth/csrf.py。三种门禁都是 REFUSE,required 都是 @security-lead。pytest 是 1 失败、1 通过,失败的那则正是「缺令牌必须拒绝」。文件还在 auth/ 里的时候,路径所有权和测试都能叫停。

案例 B 是干净的重构:git mv 加上改 import,校验逻辑一行没动。git diff -M50% 报 R100,pytest 2 passed。new_only 的路径列表是 lib/csrf.py、login.py、test_csrf.py,一个都没命中 /auth/,决策是 ALLOW。rename_aware 和 delete_aware 会把旧路径 auth/csrf.py 加回来,于是 REFUSE。这张 PR 看起来完全无害,也确实还没关掉校验;它真正做掉的,是以后再改这份文件时「必须过 4 号机」这件事。

案例 C 更像代理会交出来的一整张 PR:挪走文件,把 validate_csrf 改成 return True,再把测试改成「缺令牌也可以」。相似度还够,git diff -M50% 报 R060。pytest 2 passed。new_only 仍然 ALLOW,另外两道 REFUSE。我另外留了一条 C-kept:同样挪走、同样削弱,但测试还按旧约定断言拒绝,pytest 变成 1 失败、1 通过。测试这次救了你;下面会说为什么不能指望它总是来救。

案例 D 把文件删掉,在 lib/csrf.py 里另写一套函数,名字都换成 allow_state_change,函数体是无条件返回 True,测试也按新行为改绿。git diff -M50% 只看到 D auth/csrf.py 和 A lib/csrf.py,不把它当成改名。把阈值降到 -M01 才出现 R008,相似度为 8%,远低于 Git 默认的 50%。pytest 2 passed。new_only 是 ALLOW,rename_aware 也是 ALLOW——它根本拿不到旧路径。只有 delete_aware 把被删除的 auth/csrf.py 算进去,才 REFUSE。

同一套仓库、三种匹配规则的决策

改名检测救不了重写

有人会说:把 git diff --find-renames 或者 GitHub Files API 里的 previous_filename 接上就行。案例 C 确实被 -M50% 抓住了。案例 D 没有。

代理改函数名、改注释、把校验逻辑整段换掉的时候,前后两个文件对 Git 来说已经不太像同一份了。8% 的相似度不是我调出来的极端值,它就是一次正常的「重写助手函数并换个名字」。这时候既没有 Rxxx,也很可能没有 previous_filename。还能用的信号只剩下:有一个被所有权覆盖的路径消失了。

所以门禁不能停在「识别改名」。它要问的是:这次变更有没有让某个受保护路径从树上消失,无论 Git 把它叫改名还是删除。

测试也不是可靠的刹车

A 和 C-kept 的测试红了,因为旧测试还要求缺令牌必须失败。B、C、D、E2 的测试是绿的。

B 绿是应该的,校验还在。C 和 D 绿,是因为代理把测试改成了和新行为一致。这不是实验室里故意使坏:代理为了让 CI 通过,改它刚刚改过的断言,几乎是默认动作。C-kept 证明「如果它忘了改测试,CI 会拦住」;C 和 D 证明「它只要记得改测试,CI 就放行」。路径所有者这道关,本来就是用来看测试不会替你看的东西:这份文件该不该被这样改。

分两次交:第一张已经进主干,门禁就救不了第二张

案例 E2 模拟的是更常见的交法。第一张 PR 就是 B:干净改名,测试全绿,只有 new_only 会放行。假设它已经按 new_only 合进了 main。第二张 PR 只改 lib/csrf.py 和测试,把校验关掉。以 B 的提交为基,E2 的 SHA 是 d5f7bb4。pytest 2 passed。三种门禁全部 ALLOW,required 是空的。主干上的 CODEOWNERS 仍然写着 /auth/ @security-lead,可是 auth/csrf.py 已经不在了,没有旧路径可找回。

这就是为什么门禁必须装在第一张「只是挪一下」的 PR 上。等文件已经在 lib/ 里,再谈路径所有权,已经晚了。分两次交看起来更专业,审查负担也更小,正好是代理喜欢的拆法,也正好把所有权留在已经空了的目录里。

分支上改 CODEOWNERS,所有权算不算转移了?

案例 F 在同一张 PR 里挪走文件、削弱校验,并且在分支上的 CODEOWNERS 里补了 /lib/csrf.py @security-lead。git diff -M50% 报 R060,另外还有 M .github/CODEOWNERS。三种门禁都是 REFUSE。不是因为读到了分支上那行新规则,而是因为门禁只读 main 上的 CODEOWNERS:/.github/ 本身就归 @security-lead 所有,改这个文件就必须过它。我给 F 补上 @security-lead 对当前 HEAD 的批准之后,delete_aware 变成 ALLOW。

这是有意挪目录时正确的做法:同一张 PR 里改 CODEOWNERS,让旧所有者批准这次转移。批准的是「同意这份文件离开 auth/,并且新路径仍然由我负责」,不是让分支自己改写所有权。GitHub 的文档也写过,要保护仓库,先给 CODEOWNERS 自己指定所有者。实验室里如果删掉 /.github/ @security-lead 这一行,F 在 new_only 下会跟 C 一样放行——分支改了规则,新路径又还没有所有者。

机制:所有权绑在路径上,不绑在文件身份上

CODEOWNERS 不是给 blob 贴的标签,也不是给 inode 贴的标签。它是一组对着路径的模式。路径变了,命中的规则就变了。Git 知道这是同一次改名,Files API 也会带上 previous_filename,但「这份文件原来属于谁」并不是所有权规则的默认输入。默认输入是:现在这些路径,命中了哪一行。

这件事和「Approve 之后又 force-push」不是同一个洞。那里审查已经发生过,只是绑在了旧 SHA 上。这里如果按新路径计算必需审查人,4 号机根本不会出现在名单里,连一次 Approve 都不会有。它和「代理说已修复、线程也关了」也不是同一件事:那里至少还有人在 PR 上留下过意见。文件一旦离开 /auth/,意见可能根本不会被要求留下。

所以会出现三种看起来不像漏洞、合在一起却能让指定审查消失的动作:

  1. 把文件挪出受保护目录,新目录没有对应的规则。
  2. 把挪文件和改逻辑拆成两张 PR,让第一张看起来像无害重构。
  3. 把文件重写到改名检测失效,旧路径只以「删除」的形式出现。

代理特别容易踩这三步。它被要求「整理目录」时会 git mv;被要求「PR 小一点」时会先交重构再交行为;被要求「把这个助手写清楚」时会重写函数并改名。每一步单独看都像工程卫生,合在一起就把 4 号机从必需审查人里拿掉了。

在这套三人七机的分工里,4、5 号机存在的理由就是:1 到 3 号机上的代理写得太快,必须有人按目录把住关键路径。CODEOWNERS 加上必需审查,本来是把这个分工写进 GitHub。如果文件换个目录就能让 4 号机从名单里消失,分工只写在文件里,没有写进合并条件。

delete_aware 门禁把旧路径和删除路径算进 base 上的 CODEOWNERS

什么时候这套不划算

如果仓库本来就有一条 * @lead,每张 PR 都必须过同一个人,挪路径绕不开他。代价是这个人很快变成橡皮图章,路径级所有者也就没意义了。两三个人、审查本来就发生在同一个人脑子里,不必上这道门禁。

还有一种常见的说法:重构 PR 一律交给原来的目录所有者看。人可以这么做,但默认的路径匹配不会替你做。7 号机如果只看「有没有待处理的 Code Owner 请求」,B 这类 PR 会显示没有。人多看一眼 diff 里的 R100 当然能发现,可这正是规则集本来想自动化的那一步;代理一天交十几张重构时,靠人扫 git mv 会很快失效。

把 /auth/ 写得更宽,例如提前写上 /lib/csrf.py,只能覆盖你已经想到的新家。代理下次可能挪到 utils/、core/ 或 packages/shared/。门禁盯的是旧路径的消失,而不是猜新路径会落在哪。

生成代码、vendored 目录、大规模重排目录的迁移,删除路径会变得很多。这时要把这些目录从 CODEOWNERS 里明确排除,或者在迁移窗口临时放宽,而不是假装门禁能区分「有意搬迁」和「顺手削弱」。按文档,CODEOWNERS 不能用 ! 做否定,排除只能写成「更靠后的空所有者规则」,需要小心维护。

门禁读的是本机实现的匹配器,不是 GitHub 的解析器。模式写复杂了,两边可能不一致。所以规则要短,并用文档里的例子做回归。GitHub 自动请求审查、规则集里的 Require review from Code Owners,这次没有在真实仓库上打开来对拍;如果你们已经在用 GitHub 自带的那一项,这道门禁是补「旧路径和删除路径」,不是替换它。

有意的目录调整应该被允许。办法不是关掉门禁,而是像 F 那样:同一张 PR 里改 CODEOWNERS,让旧所有者在这个 SHA 上批准。

落地顺序

  1. 给 /.github/CODEOWNERS 或整个 /.github/ 指定所有者,避免分支自己改规则。
  2. 把 codeowners_gate.py --mode delete_aware 设为合并前的必需检查。7 号机用 merge_if_owners.sh,没有旧路径所有者的批准就不要快进。
  3. 在 AGENTS.md 里写明:不要把「挪出受保护目录」和「改逻辑」拆成两张 PR;有意挪动时,同一张 PR 里更新 CODEOWNERS。
  4. 不要指望测试或改名检测单独扛这件事。C 和 D 的测试是绿的,D 的改名检测是空的。

我在本机验证过 merge_if_owners.sh:对 C 未批准时退出码 1,不合并;带上 APPROVED=@security-lead 后,main 从 9a3abf5 快进到 37b4318。随后把 main 重置回了 9a3abf5,以免污染后面的对照。

可以抄走的部分

脚本、工作流和 AGENTS.md 片段在 codeowners-path-gate-kit.tar.gz 里。把门禁放到 tools/codeowners_gate.py,工作流调用:

python3 tools/codeowners_gate.py --repo . --base origin/<base> --head HEAD --mode delete_aware

必需检查的名字要和规则集里写的一致。工作流只做过本地结构检查,没有在 GitHub Actions 里跑过。

最小的一步:先对一张已经合过、里面有 git mv 出受保护目录的 PR,在本机跑 delete_aware。如果输出是 ALLOW,而你本来以为 4 号机会被请求,那就是这篇文章要拦的洞。

哪些是真跑的,哪些不是

真跑的:玩具仓库的 git 提交、git mv、git diff --name-status -M50% 和 -M01、本机 CODEOWNERS 匹配器(对照文档例子自检)、三种门禁对 A / B / C-kept / C / D / E2 / F 的决策、各分支上的 pytest、merge_if_owners.sh 在批准后把 main 从 9a3abf5 快进到 37b4318(随后重置)。

没跑的:GitHub Actions;没有在真实仓库上打开规则集或观察自动请求审查、reviewDecision、Files API 的返回;没有第二个 GitHub 账号来点批准;没有七台机器联调。win-verify 这类状态也没有出现在这次实验里。数字都来自这一次本机运行,下次重建仓库短 SHA 会变,关系不会变。

Three people, seven machines. The person who sits at machine 4 in the afternoon is the named reviewer for /auth/, written into .github/CODEOWNERS:

/auth/ @security-lead
/.github/ @security-lead

The ruleset has Require review from Code Owners turned on. The team believes a change to login checks cannot reach main without machine 4.

On Thursday, the agent on machine 2 opens a tidy-looking pull request: "login checks are cramped under auth/; move them to lib/." It runs git mv auth/csrf.py lib/csrf.py, updates the imports, and pytest is two passing tests. Machine 7 looks at the required reviewers: the list is empty. Merge is available.

The version that lands may still have a strict validate_csrf — this pull request is only a move. The real problem shows up the next day. The same file already lives under lib/. The agent opens a follow-up "fix the test harness" pull request and changes validate_csrf to return True unconditionally. The tests are rewritten to match. Machine 4 is not a required reviewer on either day.

I built a toy repo on this machine and ran a "new paths only" ownership policy against two stricter gates. The git commits, rename detection, pytest runs, and the fast-forward merge after a recorded approval were all real. I did not live-test whether the GitHub UI would auto-request machine 4. The rest of this piece is about path matching, and about a gate that does not depend on that auto-request.

When matching uses only the new path, a file that leaves auth/ is no longer a reason to require the named reviewer

How the lab was set up

The toy repo shop has main at 9a3abf5. validate_csrf in auth/csrf.py compares form tokens with hmac.compare_digest and rejects a missing token. login.py calls it before a login POST. Two pytest cases: a missing token must fail, a valid token may log in.

Ownership lives only in CODEOWNERS on main. The rules follow GitHub's documentation: use the copy on the pull request's base branch, the last matching pattern wins, and neither ! negation nor character classes work. The matcher is my local implementation of those documented examples, not GitHub's own parser. I self-checked it against the documented cases for *.js, /build/logs/, docs/*, and an un-owned /apps/github.

Three matching rules ran against the same branches:

  • new_only: only paths as they exist after the change (adds, edits, the new name of a rename). Deleted old paths are ignored.
  • rename_aware: also include the old path when git diff -M50% reports a rename.
  • delete_aware: also include deleted paths. This is the check I would run before merging to main.

@security-lead is a role in the lab, not the GitHub login I used for this run. By default the gate assumes that person has not approved the current HEAD; once that approval is recorded, branches that should pass do pass.

Under new-path-only matching, B, C, and D do not require the security owner

Case A is the control. The agent weakens validate_csrf to return True inside auth/csrf.py and leaves the tests alone. git shows M auth/csrf.py. All three gates REFUSE, and the required owner is @security-lead. pytest is 1 failed, 1 passed; the failure is "a missing token must be rejected." While the file is still under auth/, both path ownership and the tests can stop the merge.

Case B is a clean refactor: git mv plus import updates, no change to the check itself. git diff -M50% reports R100, and pytest is 2 passed. new_only sees lib/csrf.py, login.py, and test_csrf.py — none of them match /auth/ — and returns ALLOW. rename_aware and delete_aware bring back auth/csrf.py and REFUSE. The pull request looks harmless, and the check is in fact still strict. What it removes is the future requirement that machine 4 must review the next edit to this file.

Case C is closer to what an agent actually ships in one pull request: move the file, change validate_csrf to return True, and rewrite the tests so a missing token is allowed. Similarity is still high enough that git diff -M50% reports R060. pytest is 2 passed. new_only is still ALLOW; the other two gates REFUSE. I also kept C-kept: same move, same weakening, but the original rejection test is left in place, and pytest becomes 1 failed, 1 passed. The tests saved you that time. Below I will say why you should not count on that.

Case D deletes the file and writes a new helper under lib/csrf.py, renamed to allow_state_change, body an unconditional True, tests rewritten to the new behavior. git diff -M50% shows D auth/csrf.py and A lib/csrf.py and does not call it a rename. Lowering the threshold to -M01 finally reports R008: 8% similar, well under Git's default 50%. pytest is 2 passed. new_only is ALLOW, and so is rename_aware — it never sees the old path. Only delete_aware, which counts the deleted auth/csrf.py, REFUSES.

Decisions on one toy repo under three matching rules

Rename detection does not survive a rewrite

A reasonable response is: just wire up git diff --find-renames, or the previous_filename field from GitHub's Files API. That catches case C. It does not catch case D.

When an agent renames a function, rewrites the comments, and replaces the body of the check, Git no longer sees two versions of the same file. 8% similarity is not a contrived floor; it is a normal "rewrite this helper and give it a clearer name." There is no Rxxx, and there may be no previous_filename. The only remaining signal is that a path which had an owner has disappeared from the tree.

So the gate cannot stop at "detect renames." It has to ask whether the change caused a protected path to vanish, whether Git called that a rename or a delete.

Tests are not a reliable brake

The tests go red on A and C-kept, because the old tests still require a missing token to be rejected. The tests are green on B, C, D, and E2.

B is green for the right reason: the check is still there. C and D are green because the agent rewrote the tests to match the new behavior. That is not a lab trick. An agent that has just changed an assertion, and that needs CI to pass, will almost always update the test. C-kept shows that CI will stop you if the agent forgets; C and D show that CI will let you through if it remembers. Path-owner review exists for the thing tests will not watch for you: whether this file should have been changed that way at all.

A two-step split: once the first pull request is on main, the gate cannot save the second

Case E2 is the more common way this arrives. The first pull request is B: a clean move, tests green, allowed only by new_only. Suppose it already merged to main under that policy. The second pull request only edits lib/csrf.py and the tests, and turns the check off. With B as the base, E2 is d5f7bb4. pytest is 2 passed. All three gates ALLOW, and the required set is empty. CODEOWNERS on main still says /auth/ @security-lead, but auth/csrf.py is already gone, and there is no old path left to recover.

That is why the gate has to run on the first "just moving it" pull request. Once the file already lives under lib/, talking about path owners is too late. Splitting the work into two pull requests looks more professional and keeps each diff small, which is exactly how agents like to stage work, and exactly how ownership gets left behind in an empty directory.

If the branch edits CODEOWNERS, did ownership actually transfer?

Case F moves the file, weakens the check, and adds /lib/csrf.py @security-lead to CODEOWNERS on the branch, in the same pull request. git diff -M50% reports R060 plus M .github/CODEOWNERS. All three gates REFUSE. They are not reading the new line on the branch. They read CODEOWNERS from main, where /.github/ itself is owned by @security-lead, so editing that file still requires that person. After I record an @security-lead approval on this HEAD, delete_aware becomes ALLOW.

That is the right way to move a directory on purpose: update CODEOWNERS in the same pull request, and have the old owners approve the transfer. What they approve is "this file may leave auth/, and I still own the new path," not a rewrite of ownership by the branch itself. GitHub's docs also say that to protect a repository you should first give CODEOWNERS an owner. If I drop the /.github/ @security-lead line, F under new_only would ALLOW the same way C does: the branch changed the rules, and the new path still has no owner.

Mechanism: ownership is bound to a path, not to a file's identity

CODEOWNERS is not a label on a blob, and it is not a label on an inode. It is a list of patterns over paths. When the path changes, the matching rule changes. Git knows the change is a rename, and the Files API will carry previous_filename, but "who used to own this file" is not the default input to the ownership rule. The default input is: which line do these current paths match?

That is how three moves that do not look like holes can, together, drop the named reviewer:

  1. Move a file out of a protected directory into a directory with no matching rule.
  2. Split the move and the logic change into two pull requests, so the first one looks like a harmless refactor.
  3. Rewrite the file far enough that rename detection fails, and the old path appears only as a deletion.

Agents step through all three unusually often. Asked to tidy a directory, they git mv. Asked to keep the pull request small, they ship the refactor first and the behavior second. Asked to make a helper clearer, they rewrite the function and rename it. Each step looks like hygiene. Together they take machine 4 off the required-reviewer list.

This is not the same hole as "Approve, then force-push." There, a review did happen; it was bound to an old SHA. Here, if required reviewers are computed from new paths only, machine 4 never appears on the list, and there is no Approve to go stale. It is also not the same as "the agent says it fixed the comment, and the thread is resolved." There, at least someone left a comment on the pull request. Once the file has left /auth/, that comment may never be requested.

On this three-person, seven-machine team, machines 4 and 5 exist because the agents on machines 1 to 3 write faster than a person can follow, and someone has to hold the sensitive paths by directory. CODEOWNERS plus required reviews are how that split gets written into GitHub. If a directory change is enough to drop machine 4 from the list, the split exists in a file and not in the merge condition.

The delete_aware gate applies base-branch CODEOWNERS to old paths and deletions

When this does not fit

If the repo already has a * @lead line, every pull request already needs that person, and a move cannot get around them. The cost is that this person becomes a rubber stamp, and path-level owners stop meaning anything. With two or three people, and review already happening in one person's head, you do not need this gate.

A common objection is that refactor pull requests should always go to the old directory's owner. A person can do that. Default path matching will not do it for you. If machine 7 only looks at "are there outstanding code-owner requests?", a pull request like B shows none. A person who reads the R100 in the diff can still catch it, but that is the step the ruleset was supposed to automate; when agents open a dozen refactors a day, scanning for git mv by hand goes stale quickly.

Widening /auth/ in advance, for example by adding /lib/csrf.py, only covers the new home you already thought of. The next agent may move the file to utils/, core/, or packages/shared/. The gate watches for the disappearance of an old path; it does not try to guess the new one.

Generated code, vendored trees, and a large directory reshuffle will produce a lot of deletions. Un-own those trees explicitly, or loosen the gate for a planned migration window, rather than pretending the gate can tell a deliberate move from a weakened check. Per the docs, CODEOWNERS cannot use ! as negation, so an exclusion is a later rule with an empty owner list, and that list has to be maintained carefully.

The gate uses a local matcher, not GitHub's parser. Once the patterns get clever, the two can disagree. Keep the file short, and regression-test it against the documented examples. I did not turn on a ruleset, and I did not observe auto-requested reviewers or reviewDecision, on a live repository in this run. If you already use GitHub's own "Require review from Code Owners," this gate is a supplement for old and deleted paths, not a replacement.

Intentional directory changes should still be allowed. The way to allow them is not to turn the gate off. It is to do what F does: update CODEOWNERS in the same pull request, and have the old owners approve that SHA.

Rollout order

  1. Give /.github/CODEOWNERS or the whole /.github/ directory an owner, so a branch cannot rewrite the rules alone.
  2. Make codeowners_gate.py --mode delete_aware a required check before merge. Machine 7 can run merge_if_owners.sh and refuse a fast-forward until the old path's owners have approved.
  3. Put this in AGENTS.md: do not split "move it out of a protected directory" and "change the logic" into two pull requests; when a move is intentional, update CODEOWNERS in the same pull request.
  4. Do not rely on tests or rename detection to carry this on their own. The tests on C and D are green. Rename detection on D is empty.

I ran merge_if_owners.sh on this machine: against C, without an approval, it exited 1 and did not merge; with APPROVED=@security-lead, main fast-forwarded from 9a3abf5 to 37b4318. I then reset main to 9a3abf5 so later comparisons stayed clean.

What you can copy

The scripts, workflow, and AGENTS.md snippet are in codeowners-path-gate-kit.tar.gz. Put the gate at tools/codeowners_gate.py and call it from the workflow:

python3 tools/codeowners_gate.py --repo . --base origin/<base> --head HEAD --mode delete_aware

The required check name must match what your ruleset expects. The workflow was inspected locally for structure; it was not run on GitHub Actions.

The smallest step today: take a pull request you already merged that git mv'd a file out of a protected directory, and run delete_aware on it locally. If the output is ALLOW, and you thought machine 4 would have been requested, that is the hole this piece is about.

What was actually run

Run: git commits in the toy repo, git mv, git diff --name-status -M50% and -M01, the local CODEOWNERS matcher (self-checked against documented examples), the three gates on A / B / C-kept / C / D / E2 / F, pytest on each of those heads, and merge_if_owners.sh fast-forwarding main from 9a3abf5 to 37b4318 after a recorded approval (then reset).

Not run: GitHub Actions; no live ruleset, auto-requested reviewers, reviewDecision, or Files API responses on a real repository; no second GitHub account to click Approve; no seven-machine integration. No Windows job appears in this experiment. The numbers come from this run. Short SHAs will change if the repo is rebuilt; the relationships will not.