cd /news/developer-tools/programmatic-codeowners-edits Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-116616] src=github.com β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

Programmatic Codeowners Edits

Jordon Peterson released codeowners-tool v1, a programmatic tool for making safe, provable edits to GitHub CODEOWNERS files across 100+ repositories, including regulated environments. The tool treats ownership as a property of files rather than lines, validates policies as JSON, and refuses to write anything it cannot prove correct against the repo's actual files. It supports github.com and GitHub Enterprise Server, and is available via Homebrew, CI actions, and other installation methods.

read7 min views1 publishedAug 31, 2026
Programmatic Codeowners Edits
Image: Michielbdejong (auto-discovered)

Make safe, provable changes to GitHub CODEOWNERS files β€” in one repo, or across a hundred. Designed for rolling out programmatic CODEOWNERS changes across 100+ repositories, including regulated environments. This enables:

  • platform teams to control platform-owned files
  • AI agents to self-approve specific files when GitHub's required CODEOWNERS review setting is enabled Behavior is covered by an extensive edge-case test suite.

The problem. CODEOWNERS is written in lines, but what anyone cares about is who owns which file. The two are connected by rules that surprise people: the last matching line wins, and owner sets don't combine β€” appending /x/ @team-2

replaces the owners of /x/

, it doesn't add to them.

So you write down the ownership you want β€” "@org/platform

should co-own /services/api/

" β€” and this tool works out the lines, checks its work against every file in your repo, and refuses to write anything it can't prove correct. It also reads: snapshot

for who owns what today, audit

for what has rotted. Works with github.com and GitHub Enterprise Server.

brew install jordonpeterson/tap/codeowners-tool

In CI, uses: jordonpeterson/codeowners-tool@v1

. Every other route β€” curl | sh

with build-provenance verification, direct download, go install

, GHES, upgrading, uninstalling β€” is in ** docs/INSTALL.md**.

A policy is a JSON file stating the ownership you want. It is reviewable as a diff, it runs unchanged against one repo or a hundred, and everything that changes what gets written lives inside it rather than in a shell line nobody kept.

{
  "version": 1,
  "name": "api co-ownership",
  "ops": [
    "add_owner(/services/api/, @org/platform)",
    "add_owner(/docs/, @org/docs-team)"
  ]
}

Validate, then preview. check

reads no repository at all β€” the cheapest way to catch a broken policy before repo #1 β€” and nothing is written until you drop --dry-run

:

$ codeowners-tool check --policy ownership.json
ok: ownership.json β€” 2 op(s), no policy errors
$ codeowners-tool sync --policy ownership.json --dry-run
applied: 2 op(s) applied, 0 skipped; 2 line change(s), 2 path(s) change owners
  ops[0]  applied (proven: tree)
  ops[1]  applied (proven: tree)

proven: tree

means the claim was checked against the repo's real files, not just reasoned about. Dropping --dry-run

writes it, and the file becomes:

$ cat .github/CODEOWNERS
*            @org/everyone
/docs/ @org/everyone @org/docs-team
/services/api/   @org/api-team @org/platform

Notice what happened. @org/everyone

was carried onto the new /docs/ line β€” they owned it via

*

, and add_owner

means co-own, so the new rule restates them or they'd be dropped. Each line went directly after the rule it narrows, and

/services/api/

kept its spacing. Runs are idempotent, and every untouched byte survives.Beyond ops

, a policy carries create

(permission to write a CODEOWNERS where a repo has none), on_empty

, max_paths_changed

, defaults

and a lint

block β€” every field in POLICY-FILE.md.

One-off changes.--op 'add_owner(/docs/, @org/docs-team)'

runs a single op with no file. It is for exploring; anything you'd want reviewed, or run twice, belongs in a policy.

Ownership is a property of files, not lines. *.go @org/eng

is not a fact; the fact is that services/api/main.go

is owned by @org/eng

β€” unless some later line also matches it, in which case that line wins outright and @org/eng

is simply gone. This tool works in terms of files, and treats the lines as an implementation detail it derives for you.

Each entry in ops is one intent. Scope is a directory, file path, or glob in CODEOWNERS pattern syntax; a space or a comma is escaped with a backslash (

docs/release\ notes.md

, /a\,b/

). Where an op takes an owner it also takes a bracketed listβ€”

add_owner(/services/api/, [@org/platform, @org/sre])

β€” one line change rather than two, and for remove_owner

the only always-correct spelling.

Op What it means
add_owner(scope, owner)
Owner β€” or [owners] , or an owners array in the policy β€” becomes a co-owner. Every pre-existing owner of every path in scope is kept.
set_owners(scope, [owners])
This exact set β€” the list, or an owners array β€” owns every path in scope, displacing whoever owned it. [] is legal and deliberately un-owns the scope.
remove_owner(scope, owner)
Owner β€” or [owners] , or an owners array β€” stops owning every path in scope. If that would empty a rule, you must say what happens β€” see
on_empty
rename_owner(old, new)
Global identifier substitution β€” the only op that is safe as plain text replacement.

add_owner

and set_owners

are the two you'll use most, and picking the wrong one is the mistake this tool exists to prevent. Against /services/api/ @org/api-team

:

The op in your policy The line afterwards
add_owner(/services/api/, @org/team-1)
/services/api/ @org/api-team @org/team-1
set_owners(/services/api/, [@org/team-1])
/services/api/ @org/team-1

** @org/api-team survives the first and is gone from the second** β€” which is what

set_owners

was asked for, explicitly. By hand both look like the same one-line edit, and that's the trap: adding /services/api/ @org/team-1

at the bottom of a file silentlyperforms the second. (Ops can also carve out sub-paths with an

.)

except

clauseTwo invariants hold on every write, or the write doesn't happen:

INV-1β€” every pathin scopeends up owned exactly as the op says.INV-2β€” every pathout of scopeends up owned exactly as it was. This is the product.

Anything it can't prove β†’ it refuses and writes nothing. Refusing is a normal outcome for some repos, not a bug β€” GUIDE.md shows what to do.

Pattern note.README.md

is unanchored, so like gitignore it matches aREADME.md

atanydepth. Write/README.md

if you mean only the one at the root.

snapshot

and audit

write nothing and are safe against anything. Write your policy against what they tell you, not against what the file looks like:

$ codeowners-tool snapshot | jq .ownership
{
  "services/api/main.go": ["@org/api-team"],
  "services/web/app.ts": ["@org/everyone"]
}

snapshot

answers the question CODEOWNERS itself doesn't. []

means a rule matches and deliberately assigns no owners, null

that no rule matches at all. It reads the file committed at --branch

β€” what GitHub sees β€” so commit first.

audit

reports what's broken or rotten and lint

repairs a subset. Offline it checks the git tree: dead patterns, case-only mismatches, shadowed rules, unowned paths. With a token it also checks that owners exist, are in the org, and have explicit write access β€” the check that catches the most real rot.

Exit 4 means findings, 0 means clean β€” your CI gate. Exit 5 means a check was inconclusive: the audit fails closed and never proposes a removal it can't verify. Full check table: ** docs/LINTING.md**.

--repo

points at any local clone, and the policy path stays relative to where you are β€” so one reviewed artifact sits outside every repository it governs. The tool never clones; that stays in your script.

{ "version": 1, "name": "org baseline",
  "ops": [ { "op": "add_owner(/services/api/, @org/platform)", "on_zero_match": "skip" },
           { "op": "add_owner(/services/web/, @org/web-team)", "on_zero_match": "skip" } ] }
bash
$ codeowners-tool sync --repo clones/api-service --policy baseline.json
applied: 1 op(s) applied, 1 skipped; 1 line change(s), 1 path(s) change owners
  ops[0]  applied (proven: tree)
  ops[1]  skipped: scope "/services/web/" matches zero tracked files and on_zero_match=skip (R-21)
$ codeowners-tool sync --repo clones/web-app --policy baseline.json
applied: 1 op(s) applied, 1 skipped; 1 line change(s), 1 path(s) change owners
  ops[0]  skipped: scope "/services/api/" matches zero tracked files and on_zero_match=skip (R-21)
  ops[1]  applied (proven: tree)

Same bytes, two repos, each converging on what it actually has. skip

means "if this repo has it"; the default require

treats a scope matching nothing as a problem with this repo and exits 2

β€” what you want when every repo really should have the path. Exit 3

means the policy is broken everywhere, so a fleet stops rather than recording it a hundred times, which is why check --policy

is worth running before repo #1. The rollout script and the jq

habits that stop a silent no-op looking like success: ** docs/FLEET.md**.

β€” worked end-to-end changes: bootstrap a file, modify one, review a change, understand a refusal.GUIDE.mdβ€” the lookup tables: flags, policy fields, op semantics, JSON fields, exit codes, audit checks, guarantees.REFERENCE.mdβ€” audit and repair, and every error they can print.LINTING.mdβ€” rolling one policy across many repos.FLEET.mdβ€” glossary, and habits that save you.CONCEPTS.mdβ€” generated from the tests; everyBEHAVIOR.mdR-

,S-

,INV-

andA-

id is looked up here.β€” what the suite proves;TESTING.mdβ€” every install route, provenance, GHES.INSTALL.mdΒ·CONTRIBUTING.mdΒ·SECURITY.mdCHANGELOG.md

The test suite is the specification and BEHAVIOR.md is generated from it β€” what that proves, and how: ** docs/TESTING.md**.

MIT β€” see ** LICENSE**, and

for vendored-code attribution.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @jordon peterson 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/programmatic-codeown…] indexed:0 read:7min 2026-08-31 Β· β€”