{"slug": "programmatic-codeowners-edits", "title": "Programmatic Codeowners Edits", "summary": "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.", "body_md": "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:\n\n- platform teams to control platform-owned files\n- 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.\n\n**The problem.** CODEOWNERS is written in *lines*, but what anyone cares about is *who\nowns which file*. The two are connected by rules that surprise people: the **last**\nmatching line wins, and owner sets don't combine — appending `/x/ @team-2`\n\n**replaces**\nthe owners of `/x/`\n\n, it doesn't add to them.\n\nSo you write down the ownership you want — \"`@org/platform`\n\nshould co-own\n`/services/api/`\n\n\" — and this tool works out the lines, checks its work against every file\nin your repo, and **refuses to write anything it can't prove correct.** It also reads:\n`snapshot`\n\nfor who owns what today, `audit`\n\nfor what has rotted. Works with github.com and\nGitHub Enterprise Server.\n\n```\nbrew install jordonpeterson/tap/codeowners-tool\n```\n\nIn CI, `uses: jordonpeterson/codeowners-tool@v1`\n\n. Every other route — `curl | sh`\n\nwith\nbuild-provenance verification, direct download, `go install`\n\n, GHES, upgrading,\nuninstalling — is in ** docs/INSTALL.md**.\n\n**A policy is a JSON file stating the ownership you want.** It is reviewable as a diff, it\nruns unchanged against one repo or a hundred, and everything that changes what gets written\nlives inside it rather than in a shell line nobody kept.\n\n```\n{\n  \"version\": 1,\n  \"name\": \"api co-ownership\",\n  \"ops\": [\n    \"add_owner(/services/api/, @org/platform)\",\n    \"add_owner(/docs/, @org/docs-team)\"\n  ]\n}\n```\n\n**Validate, then preview.** `check`\n\nreads no repository at all — the cheapest way to catch\na broken policy before repo #1 — and nothing is written until you drop `--dry-run`\n\n:\n\n``` bash\n$ codeowners-tool check --policy ownership.json\nok: ownership.json — 2 op(s), no policy errors\n$ codeowners-tool sync --policy ownership.json --dry-run\napplied: 2 op(s) applied, 0 skipped; 2 line change(s), 2 path(s) change owners\n  ops[0]  applied (proven: tree)\n  ops[1]  applied (proven: tree)\n```\n\n`proven: tree`\n\nmeans the claim was checked against the repo's real files, not just\nreasoned about. Dropping `--dry-run`\n\nwrites it, and the file becomes:\n\n``` bash\n$ cat .github/CODEOWNERS\n*            @org/everyone\n/docs/ @org/everyone @org/docs-team\n/services/api/   @org/api-team @org/platform\n```\n\nNotice what happened. `@org/everyone`\n\nwas **carried onto the new /docs/ line** — they\nowned it via\n\n`*`\n\n, and `add_owner`\n\nmeans co-own, so the new rule restates them or they'd be\ndropped. Each line went **directly after the rule it narrows**, and\n\n`/services/api/`\n\nkept\nits spacing. Runs are idempotent, and every untouched byte survives.Beyond `ops`\n\n, a policy carries `create`\n\n(permission to write a CODEOWNERS where a repo has\nnone), `on_empty`\n\n, `max_paths_changed`\n\n, `defaults`\n\nand a `lint`\n\nblock — every field in\n[POLICY-FILE.md](/jordonpeterson/codeowners-tool/blob/main/docs/POLICY-FILE.md#policy-file-fields).\n\nOne-off changes.`--op 'add_owner(/docs/, @org/docs-team)'`\n\nruns a single op with no file. It is for exploring; anything you'd want reviewed, or run twice, belongs in a policy.\n\n**Ownership is a property of files, not lines.** `*.go @org/eng`\n\nis not a fact; the fact is\nthat `services/api/main.go`\n\nis owned by `@org/eng`\n\n— *unless* some later line also matches\nit, in which case that line wins outright and `@org/eng`\n\nis simply gone. This tool works in\nterms of files, and treats the lines as an implementation detail it derives for you.\n\n**Each entry in ops is one intent.** Scope is a directory, file path, or glob in\nCODEOWNERS pattern syntax; a space or a comma is escaped with a backslash\n(\n\n`docs/release\\ notes.md`\n\n, `/a\\,b/`\n\n).\nWhere an op takes an owner it also takes a bracketed **list**—\n\n`add_owner(/services/api/, [@org/platform, @org/sre])`\n\n— one line change rather than two, and\nfor `remove_owner`\n\n[the only always-correct spelling](/jordonpeterson/codeowners-tool/blob/main/docs/OPERATIONS.md#naming-several-owners-in-one-op-r-33-r-39).\n\n| Op | What it means |\n|---|---|\n`add_owner(scope, owner)` |\nOwner — or `[owners]` , or an `owners` array in the policy — becomes a co-owner. Every pre-existing owner of every path in scope is kept. |\n`set_owners(scope, [owners])` |\nThis 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. |\n`remove_owner(scope, owner)` |\nOwner — or `[owners]` , or an `owners` array — stops owning every path in scope. If that would empty a rule, you must say what happens — see\n`on_empty` |\n`rename_owner(old, new)` |\nGlobal identifier substitution — the only op that is safe as plain text replacement. |\n\n`add_owner`\n\nand `set_owners`\n\nare the two you'll use most, and picking the wrong one is the\nmistake this tool exists to prevent. Against `/services/api/ @org/api-team`\n\n:\n\n| The op in your policy | The line afterwards |\n|---|---|\n`add_owner(/services/api/, @org/team-1)` |\n`/services/api/ @org/api-team @org/team-1` |\n`set_owners(/services/api/, [@org/team-1])` |\n`/services/api/ @org/team-1` |\n\n** @org/api-team survives the first and is gone from the second** — which is what\n\n`set_owners`\n\nwas asked for, explicitly. By hand both look like the same one-line edit, and\nthat's the trap: adding `/services/api/ @org/team-1`\n\nat the bottom of a file *silently*performs the second. (Ops can also carve out sub-paths with an\n\n[.)](/jordonpeterson/codeowners-tool/blob/main/docs/OPERATIONS.md#except--carving-paths-out-of-a-scope-r-26r-32)\n\n`except`\n\nclause**Two invariants hold on every write**, or the write doesn't happen:\n\n**INV-1**— every path*in scope*ends up owned exactly as the op says.**INV-2**— every path*out of scope*ends up owned exactly as it was. This is the product.\n\nAnything it can't prove → it refuses and writes nothing. Refusing is a normal outcome for\nsome repos, not a bug — [GUIDE.md](/jordonpeterson/codeowners-tool/blob/main/docs/GUIDE.md#when-it-refuses) shows what to do.\n\nPattern note.`README.md`\n\nis unanchored, so like gitignore it matches a`README.md`\n\natanydepth. Write`/README.md`\n\nif you mean only the one at the root.\n\n`snapshot`\n\nand `audit`\n\nwrite nothing and are safe against anything. Write your policy\nagainst what they tell you, not against what the file looks like:\n\n``` bash\n$ codeowners-tool snapshot | jq .ownership\n{\n  \"services/api/main.go\": [\"@org/api-team\"],\n  \"services/web/app.ts\": [\"@org/everyone\"]\n}\n```\n\n`snapshot`\n\nanswers the question CODEOWNERS itself doesn't. `[]`\n\nmeans a rule matches and\ndeliberately assigns no owners, `null`\n\nthat no rule matches at all. It reads the file\n**committed** at `--branch`\n\n— what GitHub sees — so commit first.\n\n`audit`\n\nreports what's broken or rotten and `lint`\n\nrepairs a subset. Offline it checks the\ngit tree: dead patterns, case-only mismatches, shadowed rules, unowned paths. With a token\nit also checks that owners exist, are in the org, and have **explicit write access** — the\ncheck that catches the most real rot.\n\n**Exit 4 means findings, 0 means clean** — your CI gate. Exit 5 means a check was\ninconclusive: the audit **fails closed** and never proposes a removal it can't verify.\nFull check table: ** docs/LINTING.md**.\n\n`--repo`\n\npoints at any local clone, and the policy path stays relative to where you are —\nso one reviewed artifact sits outside every repository it governs. The tool never clones;\nthat stays in your script.\n\n```\n{ \"version\": 1, \"name\": \"org baseline\",\n  \"ops\": [ { \"op\": \"add_owner(/services/api/, @org/platform)\", \"on_zero_match\": \"skip\" },\n           { \"op\": \"add_owner(/services/web/, @org/web-team)\", \"on_zero_match\": \"skip\" } ] }\nbash\n$ codeowners-tool sync --repo clones/api-service --policy baseline.json\napplied: 1 op(s) applied, 1 skipped; 1 line change(s), 1 path(s) change owners\n  ops[0]  applied (proven: tree)\n  ops[1]  skipped: scope \"/services/web/\" matches zero tracked files and on_zero_match=skip (R-21)\n$ codeowners-tool sync --repo clones/web-app --policy baseline.json\napplied: 1 op(s) applied, 1 skipped; 1 line change(s), 1 path(s) change owners\n  ops[0]  skipped: scope \"/services/api/\" matches zero tracked files and on_zero_match=skip (R-21)\n  ops[1]  applied (proven: tree)\n```\n\nSame bytes, two repos, each converging on what it actually has. `skip`\n\nmeans \"*if* this\nrepo has it\"; the default `require`\n\ntreats a scope matching nothing as a problem with this\nrepo and exits `2`\n\n— what you want when every repo really should have the path. Exit `3`\n\nmeans *the policy* is broken everywhere, so a fleet stops rather than recording it a\nhundred times, which is why `check --policy`\n\nis worth running before repo #1. The rollout\nscript and the `jq`\n\nhabits that stop a silent no-op looking like success:\n** docs/FLEET.md**.\n\n— worked end-to-end changes: bootstrap a file, modify one, review a change, understand a refusal.[GUIDE.md](/jordonpeterson/codeowners-tool/blob/main/docs/GUIDE.md)— the lookup tables: flags, policy fields, op semantics, JSON fields, exit codes, audit checks, guarantees.[REFERENCE.md](/jordonpeterson/codeowners-tool/blob/main/docs/REFERENCE.md)— audit and repair, and every error they can print.[LINTING.md](/jordonpeterson/codeowners-tool/blob/main/docs/LINTING.md)— rolling one policy across many repos.[FLEET.md](/jordonpeterson/codeowners-tool/blob/main/docs/FLEET.md)— glossary, and habits that save you.[CONCEPTS.md](/jordonpeterson/codeowners-tool/blob/main/docs/CONCEPTS.md)— generated from the tests; every[BEHAVIOR.md](/jordonpeterson/codeowners-tool/blob/main/docs/BEHAVIOR.md)`R-`\n\n,`S-`\n\n,`INV-`\n\nand`A-`\n\nid is looked up here.— what the suite proves;[TESTING.md](/jordonpeterson/codeowners-tool/blob/main/docs/TESTING.md)— every install route, provenance, GHES.[INSTALL.md](/jordonpeterson/codeowners-tool/blob/main/docs/INSTALL.md)·[CONTRIBUTING.md](/jordonpeterson/codeowners-tool/blob/main/CONTRIBUTING.md)·[SECURITY.md](/jordonpeterson/codeowners-tool/blob/main/SECURITY.md)[CHANGELOG.md](/jordonpeterson/codeowners-tool/blob/main/CHANGELOG.md)\n\nThe test suite is the specification and BEHAVIOR.md is generated from it — what that\nproves, and how: ** docs/TESTING.md**.\n\nMIT — see ** LICENSE**, and\n\n**for vendored-code attribution.**", "url": "https://wpnews.pro/news/programmatic-codeowners-edits", "canonical_source": "https://github.com/jordonpeterson/codeowners-tool", "published_at": "2026-08-31 13:40:33+00:00", "updated_at": "2026-08-31 13:53:26.250343+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Jordon Peterson", "GitHub", "GitHub Enterprise Server", "codeowners-tool"], "alternates": {"html": "https://wpnews.pro/news/programmatic-codeowners-edits", "markdown": "https://wpnews.pro/news/programmatic-codeowners-edits.md", "text": "https://wpnews.pro/news/programmatic-codeowners-edits.txt", "jsonld": "https://wpnews.pro/news/programmatic-codeowners-edits.jsonld"}}