Skip to content
HARBAN AI LABS

Git & GitHub Mastery · Chapter 19

CODEOWNERS

"We have a CODEOWNERS file. How did a change to the deployment workflow merge without the platform team seeing it?"

15 min read · Volume 3 of 4: GitHub, GitHub Actions and security

Baseline. Git 2.55.0 on macOS; GitHub facts as of 1 October 2026, with the quoted documentation pages re-read on 2 October 2026. Transcripts are real output from labs/ch18/ (the CODEOWNERS demos live there). Git cannot evaluate a CODEOWNERS file. Every statement about how GitHub matches patterns or requests reviews comes from its documentation and carries the link; where a transcript uses Git's ignore matcher, the text says exactly how the two syntaxes differ.

19.1 Why this matters

"We have a CODEOWNERS file. How did a change to the deployment workflow merge without the platform team seeing it?"

There are six ordinary answers: no rule required the review; a later line in the file took the path away from the team; the team has no explicit write access, so its line was skipped; the pull request was a draft; the file that counted was the one on the base branch; or the person who merged could bypass the rule. Each is documented behavior. None is a bug.

19.2 What CODEOWNERS is

In one sentence. CODEOWNERS is a text file in the repository that maps path patterns to users and teams, which GitHub uses to request reviews automatically and, if a rule says so, to require them.

Analogy. A building directory in the lobby tells a visitor whom to call. It locks no door. The lock is a separate device (a ruleset) that can be told to consult the directory. The analogy breaks because this directory is read top to bottom and the last line that fits wins.

Precisely. "Code owners are automatically requested for review when someone opens a pull request that modifies code that they own. Code owners are not automatically requested to review draft pull requests" (about code owners (external site)). Three layers again:

ItemLayer
The file, its content, its historyGit: an ordinary tracked file
Reading the file, matching changed paths, requesting reviewersGitHub
Blocking the merge until an owner approvesGitHub, and only through a ruleset or a classic rule (Chapter 18, section 18.7)

Availability: code owners can be defined "in public repositories with GitHub Free and GitHub Free for organizations, and in public and private repositories" on paid plans (same page).

19.3 Where the file lives, and which one counts

In one sentence. GitHub looks in .github/, then the repository root, then docs/, uses the first file it finds, and reads it from the base branch of the pull request.

Precisely. "If CODEOWNERS files exist in more than one of those locations, GitHub will search for them in that order and use the first one it finds." And: "Each CODEOWNERS file assigns the code owners for a single branch", so main and release/1.0 can have different owners. The file "must be under 3 MB in size"; a larger one "will not be loaded", which means no review requests at all.

See it. The search order and the size are plain Git questions about the base branch:

# The documented search order, applied to the base branch of the pull request:
$ for p in .github/CODEOWNERS CODEOWNERS docs/CODEOWNERS; do git cat-file -e origin/main:$p 2>/dev/null && echo "exists on main: $p"; done
exists on main: .github/CODEOWNERS
exists on main: docs/CODEOWNERS
# The first one found is used. Its size in bytes (the documented limit is 3 MB):
$ git cat-file -s origin/main:.github/CODEOWNERS
531

Two files exist on main. The one in .github/ is used; the older docs/CODEOWNERS is dead text that will mislead whoever finds it. Delete it.

19.4 Syntax

In one sentence. Each line is a pattern followed by one or more owners; comments start with #.

The file of the sample repository, as it is on main:

$ git show origin/main:.github/CODEOWNERS
# Default owners for everything in the repository.
*                       @example-org/platform

# The routing code.
/router/                @example-org/routing
/router/priority.py     @example-org/routing @example-org/on-call

# Configuration, wherever it lives.
*.yaml                  @example-org/platform @example-org/sre

# Documentation: files directly in docs/.
docs/*                  @example-org/docs

# The files that decide who must review, and what automation runs.
/.github/               @example-org/repo-admins

The documented rules (CODEOWNERS syntax (external site)):

  • Owners are written @username or @org/team-name. An email address added to a user's account also works "in most cases", but not for managed user accounts.
  • Several owners for one pattern go on one line. "If the code owners are not on the same line, the pattern matches only the last mentioned code owner."
  • A pattern with no owner after it removes ownership: in the documented example /apps/ @octocat followed by /apps/github, changes under apps/github "can be made with the approval of any user who has write access".
  • Inline comments are allowed after the owners.
  • "CODEOWNERS paths are case sensitive, because GitHub uses a case sensitive file system", even when your Mac is not.
  • "If any line in your CODEOWNERS file contains invalid syntax, that line will be skipped." The rest of the file still applies.

Owners need write access. This is the rule that fails silently most often. "The people you choose as code owners must have write permissions for the repository. When the code owner is a team, that team must be visible and it must have write permissions, even if all the individual members of the team already have write permissions directly, through organization membership, or through another team membership." And the consequence: "If you specify a user or team that doesn't exist or has insufficient access, a code owner will not be assigned." A secret team cannot be an owner, and a team whose members can all push through the organization's base permission is not an owner until the team itself is granted write access.

19.5 Pattern matching: last match wins, and how it differs from gitignore

In one sentence. For each changed path GitHub takes the last line of the file whose pattern matches it, and the patterns follow "most of the same rules used in gitignore files", with documented exceptions.

Last match wins. "Order is important; the last matching pattern takes the most precedence." In the sample file, router/classify.py matches * and /router/; the later one decides, so the routing team owns it. The trap is a general line placed after a specific one. router/rules/eu.yaml matches *, /router/ and *.yaml. The last of the three is *.yaml, so the platform and SRE teams own it, and the routing team is not asked. Put general patterns first and specific ones last, and remember that "specific" means "later in the file", not "longer".

The pattern forms that GitHub documents with examples:

PatternDocumented meaning
*every file: the default owners
*.jsfiles with that extension, anywhere
/build/logs/that directory at the repository root, and its subdirectories
docs/*files directly in docs/, "but not further nested files like docs/build-app/troubleshooting.md"
apps/"any file in an apps directory anywhere in your repository"
/docs/the docs directory at the root "and any of its subdirectories"
**/logsany logs directory at any depth, such as /build/logs and /deeply/nested/logs

What does not work. The documentation warns about three gitignore features: "Escaping a pattern starting with # using \ so it is treated as a pattern and not a comment doesn't work", "Using ! to negate a pattern doesn't work", and "Using [ ] to define a character range doesn't work".

See it, with a caution. Git has a matcher for gitignore syntax, git check-ignore, and it is tempting to use it as a CODEOWNERS tester. The following transcripts show Git's matcher, not GitHub's. Where the two agree, the transcript illustrates the shared rule. Where they differ, the difference is the lesson.

Within one file, Git also lets the last matching pattern decide, as long as only files are involved:

# Three patterns in one gitignore-format file. check-ignore -v names the pattern that decides:
$ printf '*.py\n/router/*.py\n/router/priority.py\n' > .gitignore
$ git check-ignore -v --no-index tests/test_classify.py router/classify.py router/priority.py
.gitignore:1:*.py	tests/test_classify.py
.gitignore:2:/router/*.py	router/classify.py
.gitignore:3:/router/priority.py	router/priority.py

But gitignore works on directories first. When a pattern matches a directory, Git excludes the whole directory and never looks at the files inside it:

# Where the two part ways: a catch-all first line, then a more specific pattern.
$ printf '*\n*.py\n' > .gitignore
$ git check-ignore -v --no-index setup.py tests/test_classify.py
.gitignore:2:*.py	setup.py
.gitignore:1:*	tests/test_classify.py
# Git stops at the directory tests/, which the first line already matches.
# CODEOWNERS documentation: after "*", a later "*.js" line owns every JS file.

For tests/test_classify.py Git reports line 1, not line 2. CODEOWNERS is documented to behave differently: in GitHub's own example, * followed by *.js gives every JavaScript file to the JavaScript owner. The same mechanism explains the documented docs/* example:

# The same mechanism behind a documented example: docs/* and nested files.
$ printf 'docs/*\n' > .gitignore
$ git check-ignore -v --no-index docs/getting-started.md docs/build-app/troubleshooting.md
.gitignore:1:docs/*	docs/getting-started.md
.gitignore:1:docs/*	docs/build-app/troubleshooting.md
# Git ignores the nested file too, because docs/* matches the directory docs/build-app.
# CODEOWNERS documentation: docs/* does not match docs/build-app/troubleshooting.md.
Observed behavior : git check-ignore says docs/* matches docs/build-app/troubleshooting.md.
                    GitHub's CODEOWNERS documentation says it does not.
Git state         : none involved; this is pattern matching on path strings.
Mechanism         : gitignore patterns are applied to each directory on the way down. docs/*
                    matches the directory docs/build-app, and everything below an excluded
                    directory is excluded. CODEOWNERS assigns owners to files, one path at a time.
Root cause        : Two matchers with a shared pattern language and different jobs: one prunes
                    directory walks, the other labels files.
Why Git does this : Skipping an ignored directory without reading it is what makes status fast.
Correct fix       : Do not test CODEOWNERS with check-ignore. Reason from the documented rules,
                    then confirm on GitHub (section 19.11).
Prevention        : To own a whole subtree write the directory form, /docs/ , not docs/* .

The three documented exceptions all work in Git, which is why a pattern that "works locally" can do nothing in CODEOWNERS. Negation, for one:

# Documented as unsupported in CODEOWNERS: ! negation. In gitignore it works:
$ printf '*.yaml\n!config/routing.yaml\n' > .gitignore
$ git check-ignore -v --no-index config/routing.yaml deploy/values.yaml
.gitignore:2:!config/routing.yaml	config/routing.yaml
.gitignore:1:*.yaml	deploy/values.yaml

Case sensitivity is the last difference. Git on a Mac usually ignores case in ignore patterns, because git init sets core.ignoreCase on a case-insensitive file system. GitHub never does:

# CODEOWNERS paths are always case sensitive. Git depends on core.ignoreCase, which git init
# switches on when the file system ignores case, as the default macOS file system does:
$ printf '/docs/\n' > .gitignore
$ git -c core.ignoreCase=false check-ignore -v --no-index Docs/a.md
[exit status: 1]
$ git -c core.ignoreCase=true check-ignore -v --no-index Docs/a.md
.gitignore:1:/docs/	Docs/a.md
[exit status: 0]
gitignore (Git)CODEOWNERS (GitHub, documented)
Last matching pattern decidesyes, among patterns that reach the fileyes
A matched directory ends the searchyesno: owners are assigned per file
docs/* covers nested filesyes, through the matched subdirectoryno
! negationyesno
[ ] rangesyesno
\# to start a pattern with #yesno
Casefollows core.ignoreCasealways case sensitive

19.6 Why the file only requests reviews until a rule requires them

In one sentence. Without a rule, a code owner is a suggested reviewer whom the author may ignore; with the code-owner option of the pull request rule, the merge is blocked until one owner of every changed path approves.

Precisely. In a ruleset the option is "Require review from code owners" inside "Require a pull request before merging" (REST: require_code_owner_review). With it, "any pull request that modifies content with a code owner must be approved by that code owner before the pull request can be merged". Then the detail that weakens many designs: "if code has multiple owners, an approval from any of the code owners will be sufficient" (available rules (external site)). Two teams on one line means either team, not both.

SituationWhat happens
No ruleowners are requested as reviewers; the pull request can merge without them
Rule with code owner reviewevery changed path that has an owner needs an approval from one of its owners
A changed path has no ownerthe code-owner requirement asks nothing for that path
The pull request is a draftowners are not requested until it is marked ready
The owner is the authorauthors cannot approve their own pull requests (Chapter 17, section 17.4), so another owner of that path must; an inference from two documented rules, which Lab 23.2 lets you observe
The merger is a bypass actorthe rule does not bind them (Chapter 18, section 18.5)

That row is a reason to list a team, not one person, as owner.

19.7 The base branch decides

In one sentence. A pull request is judged by the CODEOWNERS file on its base branch, so a pull request that edits CODEOWNERS is reviewed under the old file.

Precisely. "To trigger review requests, pull requests use the version of CODEOWNERS from the base branch of the pull request." For a pull request from a fork into upstream, that is upstream's file. In a stack, CODEOWNERS is "evaluated from the stack base" (stacked pull requests (external site)).

See it. Your branch changes four paths, one of them the CODEOWNERS file, from which it removes the docs line:

# The paths this pull request changes (three dots, as on the Files changed tab):
$ git diff --name-only origin/main...HEAD
.github/CODEOWNERS
config/routing.yaml
docs/README.md
docs/runbooks/escalation.md
# The pull request also edits CODEOWNERS. On its own branch the docs line is gone:
$ git diff origin/main...HEAD -- .github/CODEOWNERS
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index a5e3975..6478d74 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -9,7 +9,6 @@
 *.yaml                  @example-org/platform @example-org/sre
 
 # Documentation: files directly in docs/.
-docs/*                  @example-org/docs
 
 # The files that decide who must review, and what automation runs.
 /.github/               @example-org/repo-admins
# Review requests come from the base version, which still has it:
$ git show origin/main:.github/CODEOWNERS | grep -n "^docs"
12:docs/*                  @example-org/docs
[exit status: 0]
$ git show HEAD:.github/CODEOWNERS | grep -n "^docs"
[exit status: 1]

The base version still has docs/*, so the docs team is requested for docs/README.md although your branch deleted their line. You cannot remove a reviewer in the pull request that needs them. For the same reason a fix to CODEOWNERS takes effect one merge later than people expect.

19.8 Protecting CODEOWNERS and the workflows directory

In one sentence. The file that names the reviewers, and the directory that defines what automation runs, need owners themselves, or anyone with write access can change both in one pull request.

GitHub's advice: "To protect a repository fully against unauthorized changes, you also need to define an owner for the CODEOWNERS file itself. The most secure method is to define a CODEOWNERS file in the .github directory of the repository and define the repository owner as the owner of either the CODEOWNERS file (/.github/CODEOWNERS @owner_username) or the whole directory (/.github/ @owner_username)."

Owning the whole .github/ directory, as the last line of the sample file does, also covers .github/workflows/. A workflow file decides what runs with the repository's token and secrets, so a change to it is a change to your deployment and security posture (Chapter 21A: GitHub Actions security). Three conditions make the protection real: the line must come last, so that no later pattern such as *.yaml takes workflow files away; the code-owner option must be required by a rule on the default branch; and the owning team must hold explicit write access.

19.9 Monorepo patterns

In one sentence. In a monorepo the file is the map of the organization, and its order has to follow the rule "general first, specific last".

# 1. Default: somebody always owns a new directory.
*                               @example-org/platform

# 2. One block per component, anchored at the root.
/services/router/               @example-org/routing
/services/billing/              @example-org/billing
/libs/eval/                     @example-org/ml-eval

# 3. Cross-cutting file types that must override the components.
/services/**/migrations/        @example-org/data
**/Dockerfile                   @example-org/platform

# 4. Last: the files that control review and automation.
/.github/                       @example-org/repo-admins

Use the directory form (/services/router/) for subtrees; dir/* covers one level only. Anchor component paths with a leading slash, or router/ also matches a directory of that name inside another component. Prefer teams to people, so that ownership survives holidays and departures. The patterns with ** follow the gitignore rules and are not among GitHub's documented examples except **/logs: confirm them with the instruments of section 19.11.

What the file cannot express: "both teams must approve", "two members of this team", or "everyone except this directory". The first two belong to the rule in the next section. The third has only the documented workaround of an owner-less line.

19.10 The newer "required reviewers" rule

In one sentence. Since February 2026 a ruleset can require approvals from named teams for file patterns, with a count per team, which covers the cases CODEOWNERS cannot express.

Precisely. In the pull request rule, "you can require review or approval from specific teams when a pull request changes certain files or directories. You can specify up to 15 different teams, and for each team you can require a certain number of approvals." Counts run from 0 to 10; zero adds the team "for visibility". The team "must have write permissions (or higher)". The rule "is not available on user-owned repositories as they do not contain teams" (available rules, required reviewers (external site)). It became generally available on 17 February 2026, and GitHub positions it with care: it "augments CODEOWNERS files but doesn't replace them. CODEOWNERS files remain the best way to manage ownership, support individuals as reviewers, and request reviews even when not required" (changelog (external site)).

CODEOWNERSRequired reviewers rule
Lives ina file in the repository, reviewed like codea ruleset, edited by administrators
Ownersusers, teams, emailsteams only
Several ownersany one sufficeseach listed team has its own required count
Negationnodocumented with !
Applies across repositoriesone file per repository and branchan organization ruleset can cover many
Visible to contributorsin the tree, and by hovering over a fileon the rules page
Takes effectwhen on the base branchwhen the ruleset is active

Chapter 18, section 18.7 records an unresolved point: the documentation page and the REST schema describe the pattern syntax of this rule differently. Test before you depend on it.

19.11 Finding out what GitHub thinks

In one sentence. Three documented instruments tell you how GitHub read the file: error highlighting on the file's page, the owner shown for each file, and a REST endpoint that lists errors.

  • "When you navigate to the CODEOWNERS file in your repository, you can see any errors highlighted."
  • When you browse to a file, hovering over the shield icon shows "a tool tip with codeownership details", from the file "for whichever branch in whichever repository you're looking at".
  • "A list of errors in a repository's CODEOWNERS file is also accessible via the API": GET /repos/{owner}/{repo}/codeowners/errors, with an optional ref (REST: list CODEOWNERS errors (external site)).
gh api repos/ORG/REPO/codeowners/errors
gh api --method GET repos/ORG/REPO/codeowners/errors -f ref=my-branch
gh pr view 12 --json reviewRequests

Each error has a line, a column, a kind, a message and sometimes a suggestion (same page). The CLI has no dedicated command for linting CODEOWNERS; the Phase 0 report looked for one. The commands above use documented endpoints and were not executed here.

The second command is how you test an edit before it merges: ask for the errors of the file as it is on your branch.

19.12 What can go wrong: common mistakes

MistakeSymptomDiagnosisFix
No rule requires code owner reviewowners are requested and ignoredthe /rules page shows no such option for the branchadd the option to the pull request rule
A general pattern after a specific onethe wrong team is requestedread the file bottom-up for the pathmove general lines to the top
A team without explicit write access, or a secret teamnobody is requested; the errors endpoint reports the linegh api .../codeowners/errorsgrant the team write access; make it visible
A user or team name misspelledas aboveas abovecorrect the name
Owners of one pattern on two linesonly the later line countsread the fileone line per pattern
!, [ ] or \# in a patternthe line does not do what it does in .gitignoresection 19.5rewrite with supported forms or an owner-less line
docs/* meant as "everything under docs"nested files fall to the default ownersection 19.5/docs/
Wrong case in a pathno matchcompare with git ls-filesmatch the case in the repository
Two CODEOWNERS filesthe edited one is not the used onesection 19.3keep one, in .github/
The fix is on a branch, not on the baseold owners are still requestedsection 19.7merge the fix first
CODEOWNERS itself has no owneranyone with write access can rewrite itread the last linesown /.github/
A sole ownerthey cannot approve their own changesection 19.6own by team

19.13 When not to use it, and dangerous edge cases

  • Do not use it as a notification list. Every pattern is a review request on every matching pull request. One busy person as owner of * becomes the bottleneck of the repository.
  • Do not treat it as access control. It decides who is asked, and with a rule who must approve. It does not stop anyone with write access from pushing to an unprotected branch.
  • "Any one owner" is not "all owners". If a change needs two teams, CODEOWNERS on one line does not give you that.
  • Bypass actors and indirect merges go around it, like every review requirement (Chapter 17, section 17.13; Chapter 18, section 18.5).

19.14 Command safety

CommandLabelWhat it changesPreviewRecovery
git show <base>:.github/CODEOWNERS, git diff --name-only <base>...HEAD, git cat-file -e🟢 SAFEnothingnot needednot needed
git check-ignore -v --no-index; gh api repos/ORG/REPO/codeowners/errors🟢 SAFEnothing; the first answers a different question (19.5)not needednot needed
Editing CODEOWNERS in a pull request🟡 CAUTIONwho is requested, and under a rule who can block, from the next merge onthe errors endpoint with refrevert the commit
Removing the owner of /.github/🔴 DANGEROUSlets any writer change reviewers and workflows without owner review. Destroys no data; removes a controlreview the diff of the filerestore the line; audit what merged in between

19.15 Version notes

TopicOlder behaviorCurrent behaviorSinceRecommended
Reviewers by pathCODEOWNERS onlyplus the required reviewers rule with per-team counts17 Feb 2026CODEOWNERS for ownership; the rule where counts or several teams are needed
Dependabot reviewersthe reviewers option in dependabot.ymlremoved; code owners are used (changelog (external site))8 Aug 2025own dependency manifests in CODEOWNERS
Stacksnot applicableCODEOWNERS evaluated from the stack base (preview)30 Jul 2026expect the base's file

19.16 Practice: pattern exercises

Use the sample file of section 19.4. For each path, name the line that decides and the owners. Reason from the documented rules of section 19.5; do not use git check-ignore. Answers are in the Module 23 answers.

  1. router/classify.py
  2. router/priority.py
  3. router/rules/eu.yaml
  4. config/routing.yaml
  5. docs/README.md
  6. docs/runbooks/escalation.md
  7. Docs/guide.md
  8. .github/workflows/ci.yaml
  9. README.md
  10. The team wants docs/runbooks/ owned by @example-org/on-call and everything else under docs/ by @example-org/docs. Write the lines and say where in the file they go.
  11. Someone appends *.py @example-org/python-guild as the last line "so the guild sees all Python". Which existing owners lose which paths?
  12. The line /.github/ @example-org/repo-admins is moved to the top of the file. Who owns .github/workflows/ci.yaml now?

Then do Lab 23.2, which proves enforcement with a second account.

19.17 Interview questions

  1. What does a CODEOWNERS file do when no rule refers to it?
  2. In which locations is the file looked up, in which order, and from which branch is it read for a given pull request?
  3. Explain "last match wins" with an example where a later, more general pattern takes a path away from a specific team.
  4. Name the gitignore features that do not work in CODEOWNERS, and one behavior that differs although the syntax is accepted.
  5. A team is listed as owner and is never requested. Give three documented causes.
  6. A path has two owning teams on one line and code owner review is required. Whose approval is needed?
  7. Why can a pull request not remove its own required reviewer by editing CODEOWNERS?
  8. How do you protect the CODEOWNERS file and the workflows directory, and which three conditions make that protection real?
  9. When would you use the required reviewers rule instead of, or together with, CODEOWNERS?
  10. How do you find out what GitHub thinks of your CODEOWNERS file before merging a change to it?

19.18 Sources

Primary sources (docs.github.com and the GitHub Changelog; read on 1 and 2 October 2026)

Secondary sources

  • The Phase 0 report of this course, section 2 ("CODEOWNERS requests reviews; only a rule makes them mandatory"), and its notes on the GitHub platform, section 3.

Videos

  • The Phase 0 report lists no verified video that teaches CODEOWNERS; its survey of popular beginner courses found that none covers it.

Further reading

  • Chapter 17: Pull Requests, sections 17.3 to 17.5; Chapter 18: Branch Protection and Rulesets, sections 18.5 and 18.7; Chapter 4: The Working Tree, for .gitignore itself.

Written against Git 2.55.0 on macOS; GitHub facts as of 1 October 2026. Git transcripts are output from the book’s lab scripts; GitHub behaviour is described from GitHub’s documentation. Found a mistake? Tell us through the enquiry form.

Model registry/models
Benchmarks/benchmarks
Paper breakdowns/research
AI tools directory/tools
Work with us/work-with-us