CODEOWNERS Setup skill for Codex

Route every PR to the team that owns the code. Codex derives owners from recent commit history, writes a CODEOWNERS file that respects last-match-wins ordering, carves lockfiles and generated code out of review requests, and lints the result against git ls-files to find unowned paths and rules that never win. It then proposes a branch ruleset requiring code owner review and applies it only after you confirm.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- codeowners-setup

Installs to ~/.agents/skills/codeowners-setup/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $codeowners-setup, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Designs and audits GitHub CODEOWNERS files and the branch rulesets that enforce them - last-match-wins ordering, team-based ownership, unowned and shadowed paths, write-access requirements, review load balancing, and Require review from Code Owners in rulesets or branch protection. Includes a linter that checks coverage against git ls-files. Use when adding or restructuring CODEOWNERS, when PRs request the wrong reviewers or none, when code owner review should become required, or when moving review routing to GitLab Code Owners sections.

Use it when

  • A repo has no CODEOWNERS file or one that has drifted from the directory layout
  • Pull requests request the wrong reviewers or none at all
  • Code owner approval should become a merge requirement
  • A team reorganisation changes who owns which directories

Use something else when

  • You want guidance on doing the review itself: use code-review
  • You need CI workflow permissions and secret protection rather than review routing: use github-actions-security

How to check the result

Run scripts/check_codeowners.py with --strict for full coverage and no shadowed rules, then confirm gh api repos/{owner}/{repo}/codeowners/errors returns an empty list after merge.

The mistake it prevents

Placing a broad rule after a specific one, so last-match-wins silently hands the specific directory back to the default team.

~/.agents/skills/codeowners-setup/SKILL.md

--- name: codeowners-setup description: Designs and audits GitHub CODEOWNERS files and the branch rulesets that enforce them - last-match-wins ordering, team-based ownership, unowned and… license: MIT ---

Done well means every tracked path resolves to a team (not a lone person) that has write access, the file is ordered so the intended rule wins, nobody is auto-requested on changes they cannot judge, and the default branch actually requires code owner approval through a ruleset. Prove it with the linter and GitHub’s errors endpoint, not by reading the file.

Workflow

- [ ] 1. Inventory: existing CODEOWNERS, rulesets, teams and their repo permissions
- [ ] 2. Map directories to owning teams (from history, not guesses)
- [ ] 3. Write the file: default rule first, specific rules later
- [ ] 4. Lint locally: coverage, dead and shadowed rules
- [ ] 5. Open a PR; check the errors API after merge
- [ ] 6. With user approval, require code owner review in a ruleset
- [ ] 7. Configure team review assignment to spread load

1. Inventory

ls .github/CODEOWNERS CODEOWNERS docs/CODEOWNERS 2>/dev/null   # GitHub uses the first found, in this order
gh api repos/{owner}/{repo}/codeowners/errors                   # syntax and unknown-owner errors on the default branch
gh api repos/{owner}/{repo}/teams --jq '.[] | "\(.slug) \(.permission)"'
gh api repos/{owner}/{repo}/rulesets
gh api repos/{owner}/{repo}/branches/main/protection 2>/dev/null   # classic protection, 404 if none

These calls need network and a token with repo read access. Read AGENTS.md and CONTRIBUTING.md for any stated ownership model before proposing one.

2. Find the real owners

Ownership should follow who maintains the code today:

git log --since=12.month --format='%an' -- services/billing | sort | uniq -c | sort -rn | head
git log --since=12.month --name-only --format= | cut -d/ -f1-2 | sort | uniq -c | sort -rn | head -30

Map people to GitHub teams with the user; never invent team slugs. If a directory has no clear owner, assign the default team and list it as an open question.

Matching rules that decide who gets requested

  • Last matching line wins. Later, more specific rules override earlier ones; there is no merging of owners across lines.
  • Patterns follow gitignore style: *.js matches anywhere; /build/ is anchored to the root; apps/ matches any directory named apps; docs/* matches direct children only, not docs/a/b.md; **/logs matches at any depth.
  • Not supported on GitHub (line is skipped): ! negation, [a-z] ranges, \# escapes.
  • A pattern with no owners makes those paths explicitly unowned, which is how to carve out lockfiles or generated code from a broad rule.
  • Owners are @user, @org/team or an email linked to a GitHub account. Teams must be visible and both users and teams need write access, or the line is ignored.
  • Paths are case-sensitive. The file has a 3 MB limit.
  • GitHub reads CODEOWNERS from the PR’s base branch, so edits take effect only after merge.

File template

# Default: platform team reviews anything not claimed below.
*                               @acme/platform

# Product areas
/apps/web/                      @acme/web
/apps/web/src/payments/         @acme/payments @acme/web
/services/billing/              @acme/payments
/packages/ui/                   @acme/design-system

# Cross-cutting files
*.sql                           @acme/data-platform
/infra/                         @acme/sre
/.github/workflows/             @acme/sre @acme/security

# Generated or tool-owned: no review request
/pnpm-lock.yaml
/apps/web/src/gql/generated/

# Ownership of this file itself
/.github/CODEOWNERS             @acme/eng-leads

Ordering rules:

SituationDo
Broad defaultFirst line, * with one team
Specific subtree must also involve the parent teamList both teams on the specific line; lines do not merge
Extension rule (*.sql) should beat directory rulesPut it after them
Directory rule should beat an extension rulePut the directory rule after
Lockfiles, snapshots, codegenOwnerless line at the end so they never trigger requests
Security-sensitive paths (auth, CI, infra)Last, so nothing overrides them

Lint before committing

python3 scripts/check_codeowners.py .                 # syntax, dead rules, shadowed rules, coverage
python3 scripts/check_codeowners.py . --strict        # also fail on unowned files and dead rules (CI)
python3 scripts/check_codeowners.py . --explain apps/web/src/payments/api.ts pnpm-lock.yaml

The script reads git ls-files, applies last-match-wins, and exits 1 on syntax GitHub would skip. Treat SHADOW lines as bugs: a rule that never wins is either misordered or redundant. Its matcher approximates GitHub’s; confirm surprising results with the errors API or by opening a test PR. After merge, gh api repos/{owner}/{repo}/codeowners/errors must return an empty errors array.

Enforce with a ruleset

Changing repository rules alters who can merge; show the exact JSON to the user and get an explicit yes before applying it. Prefer rulesets over classic branch protection: they stack, can target many branches and show in the UI as reviewable objects.

{
  "name": "default-branch-reviews",
  "target": "branch",
  "enforcement": "active",
  "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
  "rules": [
    { "type": "deletion" },
    { "type": "non_fast_forward" },
    {
      "type": "pull_request",
      "parameters": {
        "required_approving_review_count": 1,
        "require_code_owner_review": true,
        "dismiss_stale_reviews_on_push": true,
        "require_last_push_approval": true,
        "required_review_thread_resolution": false
      }
    }
  ]
}
gh api repos/{owner}/{repo}/rulesets --method POST --input ruleset.json   # only after approval
  • With require_code_owner_review, a PR touching several areas needs one approval from an owner of each matched path.
  • An author’s own approval never counts. If a path’s only owner is one person, their own PRs there cannot merge without a bypass. Always own paths with teams of two or more.
  • Add bypass actors (an admin role or a release bot) deliberately and sparingly; every bypass is a hole in the requirement.
  • Rulesets in evaluate mode (log without enforcing) are available on some plans; use it to measure impact first when offered.

Spread the review load

Requesting a whole team pings everyone. In each team’s settings, enable code review assignment (round robin or load balance, one or two reviewers, and skip members who are busy). This is a UI setting per team; tell the user where it is rather than guessing an API call.

GitLab differences

GitLab supports CODEOWNERS in the same three locations with extra features: sections ([Backend] @backend-team), required approval counts per section ([Database][2]), optional sections (^[Docs]), and default owners per section header. Enforcement comes from protected branch setting “Require approval from code owners”, available on Premium and above. Do not paste GitLab section syntax into a GitHub file; GitHub skips it.

Gotchas

  • Individual owners leave or go on leave and block merges. Use teams everywhere.
  • Teams with only read access are silently ignored; owner lines then fall through to no one. The errors endpoint reports this, the file itself does not.
  • A broad * default on a monorepo with required owner review makes the platform team a bottleneck for every lockfile bump. Carve out tool-owned files.
  • Moving directories without updating CODEOWNERS leaves dead rules and unowned code; run the linter in CI with --strict.
  • Draft PRs do not request code owners until marked ready for review.

Output

## CODEOWNERS change
- Coverage: 97.4% -> 100% of tracked files owned (linter output attached)
- New owners: /services/billing/ -> @acme/payments (top 3 committers in 12 months are members)
- Removed: 2 dead rules (/legacy/, /tools/old-cli/)
- Ruleset: `default-branch-reviews` requires code owner review (applied after approval / proposed only)
- Open questions: owner for /scripts/ (no team claims it; defaulted to @acme/platform)

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "CODEOWNERS Setup"
  short_description: "Route every PR to the team that owns the code"
  default_prompt: "$codeowners-setup Set up CODEOWNERS for our monorepo so each app's team reviews its own changes"
scripts/check_codeowners.py161 lines, 5.9 KB
#!/usr/bin/env python3
"""Lint a GitHub CODEOWNERS file against the files tracked in a git repo.

Reports syntax GitHub ignores (negation, character ranges, bad owners),
tracked files with no owner, rules that match no file, and rules that are
always overridden by a later rule (last match wins). With --explain, prints
the winning rule for specific paths.

Usage:
  python3 check_codeowners.py [repo_dir] [--strict] [--explain PATH ...]

Exit codes: 0 ok, 1 problems found (syntax errors always; unowned files and
dead rules only with --strict), 2 usage error. Standard library only.
Matching follows GitHub's documented gitignore-style rules closely but is
an approximation: confirm edge cases in the GitHub UI or the codeowners
errors API.
"""
from __future__ import annotations

import argparse
import re
import subprocess
import sys
from pathlib import Path

LOCATIONS = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"]
OWNER_RE = re.compile(r"^(@[A-Za-z0-9-]+(/[A-Za-z0-9._-]+)?|[^@\s]+@[^@\s]+\.[^@\s]+)$")


def glob_to_regex(pattern: str) -> re.Pattern[str]:
    anchored = pattern.startswith("/") or "/" in pattern.rstrip("/")
    dir_only = pattern.endswith("/")
    p = pattern.strip("/")
    out, i = [], 0
    while i < len(p):
        if p.startswith("**/", i):
            out.append("(?:.*/)?"); i += 3
        elif p.startswith("**", i):
            out.append(".*"); i += 2
        elif p[i] == "*":
            out.append("[^/]*"); i += 1
        elif p[i] == "?":
            out.append("[^/]"); i += 1
        else:
            out.append(re.escape(p[i])); i += 1
    body = "".join(out)
    prefix = "^" if anchored else "^(?:.*/)?"
    last = p.rsplit("/", 1)[-1]
    if dir_only:
        suffix = "/.*$"
    elif last == "*" and "/" in p:
        suffix = "$"  # docs/* matches direct children only
    else:
        suffix = "(?:/.*)?$"
    return re.compile(prefix + body + suffix)


def parse(path: Path):
    rules, errors = [], []
    for n, raw in enumerate(path.read_text().splitlines(), start=1):
        line = raw.split(" #", 1)[0].strip() if not raw.lstrip().startswith("#") else ""
        if not line:
            continue
        parts = line.split()
        pattern, owners = parts[0], parts[1:]
        if pattern.startswith("!"):
            errors.append(f"line {n}: negation '{pattern}' is not supported; GitHub skips this line")
            continue
        if "[" in pattern or "]" in pattern:
            errors.append(f"line {n}: character ranges in '{pattern}' are not supported")
            continue
        if pattern.startswith("\\#"):
            errors.append(f"line {n}: escaping '#' with backslash is not supported")
            continue
        bad = [o for o in owners if not OWNER_RE.match(o)]
        if bad:
            errors.append(f"line {n}: invalid owner(s) {bad}; use @user, @org/team or an email")
        rules.append({"line": n, "pattern": pattern, "owners": owners,
                      "rx": glob_to_regex(pattern), "matched": 0, "won": 0})
    return rules, errors


def winner(rules, f):
    hit = None
    for r in rules:
        if r["rx"].match(f):
            r["matched"] += 1
            hit = r
    return hit


def main() -> int:
    ap = argparse.ArgumentParser(description="Lint a CODEOWNERS file")
    ap.add_argument("repo", nargs="?", default=".")
    ap.add_argument("--strict", action="store_true", help="fail on unowned files and dead rules")
    ap.add_argument("--explain", nargs="+", metavar="PATH", help="show the winning rule per path")
    args = ap.parse_args()
    repo = Path(args.repo)

    found = [repo / p for p in LOCATIONS if (repo / p).is_file()]
    if not found:
        print("error: no CODEOWNERS in .github/, root or docs/", file=sys.stderr)
        return 2
    co = found[0]
    if len(found) > 1:
        print(f"warning: GitHub uses only {found[0].relative_to(repo)}; ignoring "
              + ", ".join(str(f.relative_to(repo)) for f in found[1:]))
    if co.stat().st_size > 3 * 1024 * 1024:
        print("error: CODEOWNERS over 3 MB is ignored by GitHub")
        return 1

    rules, errors = parse(co)
    try:
        files = subprocess.run(["git", "-C", str(repo), "ls-files"], check=True,
                               capture_output=True, text=True).stdout.splitlines()
    except (subprocess.CalledProcessError, FileNotFoundError) as exc:
        print(f"error: cannot list tracked files: {exc}", file=sys.stderr)
        return 2

    if args.explain:
        for f in args.explain:
            r = winner(rules, f)
            if r is None:
                print(f"{f}: no owner (no rule matches)")
            elif not r["owners"]:
                print(f"{f}: explicitly unowned by line {r['line']} '{r['pattern']}'")
            else:
                print(f"{f}: line {r['line']} '{r['pattern']}' -> {' '.join(r['owners'])}")
        return 0

    unowned = []
    for f in files:
        r = winner(rules, f)
        if r is None or not r["owners"]:
            unowned.append(f)
        if r is not None:
            r["won"] += 1

    dead = [r for r in rules if r["matched"] == 0]
    shadowed = [r for r in rules if r["matched"] > 0 and r["won"] == 0]

    print(f"CODEOWNERS: {co.relative_to(repo)}  rules: {len(rules)}  tracked files: {len(files)}")
    for e in errors:
        print(f"SYNTAX  {e}")
    for r in dead:
        print(f"DEAD    line {r['line']}: '{r['pattern']}' matches no tracked file")
    for r in shadowed:
        print(f"SHADOW  line {r['line']}: '{r['pattern']}' always overridden by a later rule")
    pct = 100 * (len(files) - len(unowned)) / max(len(files), 1)
    print(f"COVERAGE {pct:.1f}% owned; {len(unowned)} file(s) without an owner")
    for f in unowned[:20]:
        print(f"  unowned: {f}")
    if len(unowned) > 20:
        print(f"  ... {len(unowned) - 20} more")

    failed = bool(errors) or (args.strict and (unowned or dead))
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc