← Back to the CODEOWNERS Shadow Checker
A repository has "Require review from Code Owners" turned on. For months, a change under
/src/payments/*.js has correctly required the payments team's approval before it can merge.
Then one day it doesn't -- the pull request opens, the payments team is not requested, and it merges
without them ever seeing it. Nobody touched the payments rule. Nobody touched the payments files. The
repository's own settings page still lists "Require review from Code Owners" as enabled.
GitHub does not pick the most specific matching rule for a file, and it does not pick the first one
written. It checks every pattern in the file, in order, and the last one that matches wins
-- exactly the rule .gitignore uses for exclusions. Somewhere between "worked" and "stopped
working", a new line was added below the payments rule -- for a completely unrelated reason, by someone
who had no idea a payments rule existed:
# Line 4 -- present since the repository was created, worked for months
/src/payments/*.js @payments-team
# Line 19 -- added later, for a different reason entirely
*.js @generalists
src/payments/charge.js still matches line 4, exactly as it always did. It now also matches
line 19, which sits below it. Under last-match-wins, line 19 decides ownership. Line 4 is not broken,
not removed, and not flagged by anything -- it simply never wins again, for any file it matches, from the
moment line 19 landed.
GitHub resolves CODEOWNERS the same way every time it is asked: fresh, from whatever the file currently
says, with no record of who used to be the answer. Branch protection asks "who owns this file, right
now" and gets a correct answer under the file as it stands -- @generalists. There is nothing
wrong to report, because nothing is wrong from GitHub's side: the file is valid, every owner is real, and
the resolution is exactly what last-match-wins specifies. The gap is between what the file says now and
what its author intended when they wrote line 4, and that gap has no error message, because it isn't an
error.
| Move the specific rule later | Put /src/payments/*.js @payments-team after *.js @generalists in the
file. Both rules stay; the payments rule now sits last among the ones that match payments files, so
it wins again. |
|---|---|
| Narrow the broad rule | Change *.js @generalists so it no longer matches src/payments/** -- for
example scoping it to a specific directory instead of the whole tree. The payments rule wins because
the conflict is gone, not because of position. |
Either way, the fix is a one-line edit and the failure mode does not recur for these two rules -- until a third rule broad enough to match the same files is added below both of them, which is exactly why this is worth checking after every change to the file, not just once.
check.mjs only gathers your CODEOWNERS file and tracked file list into JSON -- it does not
itself decide pass or fail, because that judgment belongs to the browser report at
index.html. To gate a CI job instead, call the same pure function the page uses,
buildReport from codeowners.mjs, directly:
import { readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { buildReport } from './codeowners.mjs';
const codeowners = readFileSync('CODEOWNERS', 'utf8');
const paths = execFileSync('git', ['ls-files'], { encoding: 'utf8' }).split('\n').filter(Boolean);
const report = buildReport(codeowners, paths.join('\n'));
if (!report.ok) {
console.error(`${report.shadowed.length} shadowed rule(s), ${report.syntaxErrors.length} syntax error(s)`);
process.exit(1);
}
That is the entire dependency: one pure module, no network call, no GitHub token. It reports evidence against the file list you gave it, same as the browser page -- see the checker's own disclaimer for what that does and does not cover.
No. Branch protection's "Require review from Code Owners" setting asks GitHub to resolve the file at merge time and request whichever team wins under last-match-wins. If a broader rule now wins instead of yours, GitHub asks that rule's owners -- correctly, by its own logic -- and nothing on the pull request says a different team used to be asked.
Yes, and this is the case that is hardest to trace. The rule itself, and every file it matches, can stay exactly as they were. All that has to change is a broader pattern being added anywhere below it in the file -- for an unrelated reason, by someone who has never looked at the earlier rule and has no reason to.
Yes -- last-match-wins is recalculated fresh from the current file on every resolution, with no memory of what won before. Removing whichever later rule now matches the same files hands the win straight back to the earlier, more specific rule, with no other change needed.
Moving it works just as well as deleting the broad rule, and is usually the smaller edit: last-match-wins only cares about position in the file, not when each rule was written. Put the specific rule after the broad one and it wins again, with both rules still present.
← Back to the checker to resolve your own CODEOWNERS file and file list and see exactly which rules are shadowed.