What it is: CODEOWNERS generated from the Nx project graph
Nx CODEOWNERS generation means producing the repository's CODEOWNERS file from ownership data that each Nx project already declares, instead of editing the file by hand. Each project records its owning team, either as a tag or as an explicit owners field in project.json. A generator, which can be Nx's own owners feature or a small script you write, reads the Nx project graph, maps each project's root directory to its owners, and writes a correctly ordered CODEOWNERS file. CI then checks that the committed file matches what the generator would produce. The result is a review-routing file that follows the workspace layout automatically. When a project is added, moved, or reassigned, the CODEOWNERS rules change with it.
How CODEOWNERS matching works and why generation must respect it
A CODEOWNERS file maps path patterns to the users or teams responsible for them. When a pull or merge request touches a matching path, the platform requests reviews from those owners automatically, and branch protection can make their approval mandatory. In a monorepo, where one change can cross several teams' projects, this is what routes review to the right people without anyone having to choose reviewers by hand.
On GitHub, the last matching pattern wins. A broad rule such as a root catch-all must come first, and more specific project paths must come after it, or they will be silently overridden. A pattern with no owners is valid and makes matching files explicitly ownerless, which is useful for carving generated or vendored directories out of a catch-all. GitLab applies last-match-wins within each section. Every matching section applies independently. Sections can require a set number of approvals or be marked optional with a leading caret.
| Aspect | GitHub | GitLab |
|---|---|---|
| File locations | .github/, repo root, docs/ |
repo root, docs/, .gitlab/ |
| Precedence | Last match wins, file-wide | Last match wins per section |
| Grouping | None | Sections, optional sections, approval counts |
| Invalid owners | Teams or users without write access are ignored | Unknown or unauthorized owners are ignored |
The last row matters most. A misspelled team, or a team that lacks write access, does not fail the build. That path simply ends up with no effective reviewer. A generator therefore has to reproduce ordering, sections, and owner validity exactly. If it only approximates them, it will produce a file that looks correct but enforces nothing.
* @acme/platform-team
/apps/storefront/ @acme/storefront-team
/libs/checkout/ @acme/payments-team
/libs/shared/ui/ @acme/design-system-team @acme/storefront-team
/libs/api-client/generated/
/libs/checkout/migrations/ @acme/payments-team @acme/dba-team
A GitHub CODEOWNERS file that puts the catch-all first, adds project-specific overrides after it, uses an ownerless pattern to exclude generated code, and marks where wrong ordering or an invalid team would silently remove enforcement.
Why treat CODEOWNERS as a build artifact derived from the Nx project graph
A hand-maintained CODEOWNERS file in an Nx workspace duplicates information the workspace already holds. Every project has a root, and most carry tags or metadata in project.json that say which team or domain it belongs to. When someone adds a library, moves an app, or reassigns a domain, that change lands in project configuration. The CODEOWNERS file only changes if someone remembers to edit it. Nothing fails when they forget, so the routing slowly drifts.
How it works
Treating CODEOWNERS as a build artifact closes that gap. The pipeline is linear:
- Ownership is declared once, per project, as tags or explicit metadata in
project.json. - Nx resolves those declarations into the project graph, which already knows each project's root.
- A generator walks the graph, maps each root to its owners, and writes a CODEOWNERS file with stable ordering, so the same graph always yields byte-identical output.
- CI regenerates the file and fails if the committed copy differs.
- Branch protection requires code owner approval, so the generated rules decide who must review.
Deterministic output matters most in step 3. Without it, diffs become noise and the CI comparison stops being a reliable check.
+---------------------------+ +---------------------------+
| project.json (per project)| | project.json (per project)|
| tags: team:payments | | metadata: owners |
+-------------+-------------+ +-------------+-------------+
| |
+----------------+-----------------+
v
+---------------------------------+
| Nx project graph |
| project -> root + tags |
+----------------+----------------+
v
+---------------------------------+
| CODEOWNERS generator |
| map root -> owners, sort stably |
+----------------+----------------+
v
+---------------------------------+
| CODEOWNERS file |
| same graph = same bytes |
+--------+---------------+--------+
| |
v v
+-----------------------+ +--------------------------+
| CI: regenerate + diff | | Branch protection: |
| fail on any mismatch | | require code owner review|
+-----------------------+ +--------------------------+
Ownership declared in each project.json flows through the Nx project graph into a generator that writes a deterministic CODEOWNERS file, which CI checks for drift and branch protection enforces as required review.
When to use generated CODEOWNERS
This approach fits monorepos where these conditions hold:
- Many projects and several teams. The more rules the file holds, the more likely it is that a hand edit will be forgotten or misordered.
- Frequent project changes. Projects are created, renamed, or moved often, so a static file falls out of date quickly.
- Team boundaries match project boundaries. Each project root has a clear owning team, so "project root → owners" covers almost every rule you need.
- Required review is enforced. Branch protection requires code owner approval, so a wrong or missing rule actually blocks merges or lets changes through unreviewed. That is the point where correctness of the file matters.
When to avoid it
It is not worth the machinery in these cases:
- Small repositories with a handful of projects. A short hand-edited file is easier to read and maintain than a generator plus a CI check.
- A single team reviews everything. One catch-all line already expresses the policy.
- Ownership follows cross-cutting paths rather than project roots, for example shared config directories, file types spread across projects, or a review policy based on what code does rather than where it lives. A generator keyed on project roots cannot express that well. You would end up hand-writing most rules anyway.
- Ownership metadata is not maintained. If nobody keeps project tags or owners fields accurate, generation only reproduces stale data faster.
Where ownership data should live: tags versus explicit project metadata
Where that machinery does pay off, the first design decision is where the generator reads ownership from. There are two common answers.
Tags. Many workspaces already tag projects for the @nx/enforce-module-boundaries lint rule, for example "tags": ["scope:payments", "type:data-access"]. Adding "owner:payments" beside them seems free: the generator scans tags with one prefix, and nothing new needs a schema. The cost is coupling. Tags exist to express architectural constraints, and lint rules match on them. If the owning team splits or renames, you have to choose between leaving a stale tag name in place and editing tags that depConstraints may already reference. Tag lists also grow. Once a project carries scope, type, platform and owner tags, reviewers struggle to tell which tags constrain dependencies and which only route reviews.
Explicit metadata. Store owners as a dedicated field holding the exact GitHub handles or teams that CODEOWNERS needs, for example "metadata": { "owners": ["@org/payments-backend"] }. The generator then copies the field instead of mapping a tag to a team, and renaming a team never touches lint configuration.
| Concern | owner:* tags |
Explicit owners field |
|---|---|---|
| Setup effort | Minimal | A small schema convention |
| Effect of a team rename | Can ripple into lint rules | Isolated to the ownership field |
| Value for CODEOWNERS | Needs a tag-to-team map | Already a valid owner |
| Readability at scale | Degrades with tag sprawl | Stays separate |
Recommendation: keep tags for architecture (scope:*, type:*) and put review routing in an explicit owners field. These are separate concerns, and storing them separately lets each one change without breaking the other.
// Approach A: owner encoded as a tag (libs/payments/data-access/project.json)
// Mixes architecture tags with review routing; the generator must map
// "owner:payments" to a real GitHub team.
{
"name": "payments-data-access",
"sourceRoot": "libs/payments/data-access/src",
"projectType": "library",
"tags": ["scope:payments", "type:data-access", "owner:payments"]
}
// Approach B (recommended): tags stay architectural, owners live in metadata
// (libs/payments/data-access/project.json)
// The generator copies metadata.owners verbatim into CODEOWNERS, so a team
// rename never touches tags that depConstraints may reference.
{
"name": "payments-data-access",
"sourceRoot": "libs/payments/data-access/src",
"projectType": "library",
"tags": ["scope:payments", "type:data-access"],
"metadata": {
"owners": ["@example-org/payments-backend"]
}
}
The same project.json written two ways: Approach A puts the owner in an owner: tag, and Approach B, the recommended one, keeps tags for architecture and stores CODEOWNERS-ready handles in metadata.owners (annotated with // comments, so strip them before using either file as real project.json).*
Option 1: the official Nx owners feature
Nx offers an owners capability that does this compilation for you. You declare who owns what in Nx configuration, and the tooling writes the platform-specific file: GitHub's CODEOWNERS, or the equivalent for GitLab or Bitbucket. Ownership can be declared at two levels:
- Per project. A project lists its owners, and the tool expands that into a rule covering the project's root directory as it appears in the project graph. If a project moves, the next generation run picks up the new path.
- Per file pattern. Inside or across projects, you attach owners to globs, such as migrations, API contracts or CI configuration. This lets a database or security group review specific files without owning the whole project.
The tool handles rule ordering. That matters because, as covered earlier, the last matching line wins. A hand-maintained file breaks quietly when someone appends a broad pattern below a narrow one. Generation from one source of truth prevents that.
Do not copy a configuration shape from a blog post, including this one. Check the current Nx documentation for your Nx version and confirm three things:
- the exact package name
- whether it requires a license or subscription separate from Nx core
- the configuration keys it expects
All three have been subject to change.
The trade-off is direct. You stop maintaining ordering logic, path expansion and output formatting yourself. In exchange, your review routing depends on Nx's configuration model, release cadence and licensing terms. If any of those change on an upgrade, ownership routing changes with them, and you have to either migrate or replace it.
Option 2: building a minimal nx codeowners generator
If an upgrade changes how the official feature behaves, you have to either accept the new behavior or rework your setup under time pressure. A small generator script avoids that dependency.
Read the graph. createProjectGraphAsync from @nx/devkit returns every project with its root, tags and metadata. For shell-only tooling, nx show projects --json lists names, and nx show project <name> --json returns each project's configuration. Either way, the graph is the only input, so the output stays consistent with what Nx itself resolves.
Map roots to owners. For each project, derive owners from an owner: tag or from explicit metadata, using whichever source the previous section settled on. Fail the run when a project has no owner. A gap that is silently skipped becomes an unreviewed directory.
Order for last-match-wins. CODEOWNERS applies the last matching pattern, so sort rules by path depth, broadest first, and break ties lexicographically. A nested root such as libs/payments/adapters then lands after libs/payments and overrides it. Sort owners within each line as well. Deterministic output keeps diffs empty unless ownership actually changed.
Place platform rules deliberately. Emit a * fallback first. Exact files such as nx.json, tsconfig.base.json, the lockfile and .github/workflows/ go last, so no project glob can claim them. Directories that may contain projects, such as tools/ or docs/, go before project rules, so a project nested inside them keeps its own owners.
Write and verify. Prepend a "generated, do not edit" header that names the regeneration command. Then run a short list of sample paths through a last-match-wins matcher and assert the expected owners for each.
import { createProjectGraphAsync } from '@nx/devkit';
import { writeFileSync } from 'node:fs';
const PLATFORM = '@acme/platform-team';
const byDepthThenPath = (a: string, b: string) =>
a.split('/').length - b.split('/').length || (a < b ? -1 : a > b ? 1 : 0);
async function main() {
const graph = await createProjectGraphAsync();
const rules: Array<[string, string]> = [];
for (const [name, node] of Object.entries(graph.nodes)) {
const owners = (node.data.tags ?? [])
.filter((t) => t.startsWith('owner:'))
.map((t) => t.slice('owner:'.length))
.sort(); // stable owner order keeps diffs empty when nothing changed
// A silently skipped project becomes an unreviewed directory, so fail loudly.
if (owners.length === 0) throw new Error(`Project "${name}" has no owner: tag`);
rules.push([`/${node.data.root}/`, owners.join(' ')]);
}
// Last match wins: broadest paths first, so libs/payments/adapters overrides libs/payments.
rules.sort(([a], [b]) => byDepthThenPath(a, b));
const lines = [
'# GENERATED by `npx tsx tools/scripts/codeowners.ts`. Do not edit by hand.',
`* ${PLATFORM}`, // fallback for anything no other rule claims
`/docs/ ${PLATFORM}`, // before project rules: projects nested here keep their owners
`/tools/ ${PLATFORM}`,
...rules.map(([path, owners]) => `${path} ${owners}`),
`/nx.json ${PLATFORM}`, // after project rules: no project glob can claim these
`/tsconfig.base.json ${PLATFORM}`,
`/package-lock.json ${PLATFORM}`,
`/.github/workflows/ ${PLATFORM}`,
];
writeFileSync('.github/CODEOWNERS', lines.join('\n') + '\n');
}
main().catch((err) => { console.error(err); process.exit(1); });
A minimal generator that reads the Nx project graph, maps owner: tags to project roots, sorts rules broadest-first for last-match-wins, and places platform-owned directories before and exact platform files after the project rules.
Enforcing freshness and validity in CI, and wiring it into review policy
Generation alone does not keep the file honest. Someone will add a project and forget to regenerate, so CI has to catch it.
Fail on drift
Add a CI job that runs the generator and then git diff --exit-code -- CODEOWNERS. A non-zero exit means the committed file no longer matches the project graph. Print the regeneration command in the failure message so the fix is obvious.
Fail on bad ownership data
Drift checks only compare output with output. They will pass a project that has no owner at all, because that project simply produces no rule and falls through to a broader pattern. Add a lint step that fails when any project lacks an owner tag, or when a tag names a team that is not in your allowlist. A typo like team:paymnets otherwise produces a rule that silently routes reviews nowhere.
Make it binding
CODEOWNERS only requests reviews until you enable "Require review from Code Owners" in branch protection. After that, merges are blocked until an owner approves.
Single vs. multiple owners
When a pattern lists several teams, approval from any one of them typically satisfies the requirement. For a shared library, listing several teams speeds up approvals but weakens accountability, because each team can merge changes the others never saw. A single owning team is slower but gives the library one clear steward. Choose deliberately.
Parallel PRs
Two PRs that each add a project will both edit CODEOWNERS and conflict. Do not resolve the conflict by hand. Rebase onto main, run the generator, commit the result, and then push the regenerated file. The merge is resolved by the generator, not by a person.
Limits, alternatives, and impact on cost and engineering time
What this cannot express
CODEOWNERS is path-based. It routes a change to the owners of the files touched, not to the teams that consume them. An edit to a shared auth library reaches its owners, but not the teams whose apps import it. To cover that gap, add a non-blocking bot comment that lists the owners of the downstream projects Nx already computes as affected. Mark it FYI so it never blocks a merge.
Root files such as the lockfile and the root package.json send most dependency bumps to whoever owns /. Make that owner a rotation rather than a single team that becomes a bottleneck.
The CI freshness check only proves the file matches the metadata. It does not prove the metadata is still true. Audit periodically for teams that were renamed, emptied, or reorganized.
| Approach | Source of truth | Ongoing effort | Fits when |
|---|---|---|---|
| Hand-edited file | The file itself | High, and it drifts | Few projects, stable teams |
| Nx owners feature | Nx configuration | Low | You want supported tooling |
| Homegrown script | Project metadata | Low, plus script upkeep | You need custom rules |
| Bot reviewer assignment | Bot config | Medium | Reviews are advisory, not required |
Illustrative estimate (assumed numbers, not a benchmark)
Monthly hours saved = (misrouted PRs × minutes lost each + manual edits × minutes each) ÷ 60
Assumed inputs:
- 40 misrouted PRs × 15 minutes each
- 8 manual edits × 10 minutes each
That gives (600 + 80) ÷ 60 ≈ 11.3 hours saved per month.
Payback period = build hours ÷ (monthly savings − upkeep hours)
Assuming 16 build hours and 1 hour of upkeep per month: 16 ÷ (11.3 − 1) ≈ 1.6 months.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nx codeowners, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.