Skip to main content

owners-yaml

Code ownership in small owners.yaml files next to the code, with a resolver that people, tools, and agents can ask, a linter, and a CODEOWNERS export.

CODEOWNERS answers one question for one consumer: who must approve this pull request on GitHub. Automation now does more of the work around a repo: review bots, coding agents that open pull requests, alert routers, flaky-test reports. Each of them needs ownership as data, and each asks a different question:

  • Which team owns this path, and who is the person to ask?
  • Which Slack channel takes this team's alerts, and has the team opted out of this bot?
  • Is this file generated or vendored, so a review can skip it?
  • Does anyone own this code at all?

owners-yaml answers all of them from one resolver, so no tool parses ownership files itself or guesses an owner from git blame.

Each directory declares its owners in a short file. Resolving a path takes more steps than a CODEOWNERS lookup: the nearest file wins field by field, inherit: false cuts off parent files, and rules override fields inside a file. So don't read the files to find an owner. Ask the resolver: owners who <path> for a person, and the library, CLI, or JSON entrypoint for a tool. GitHub's CODEOWNERS can stay in place for required approvals.

The format is defined in SPEC.md. The website at owners-yaml.posthog.dev walks through setup with commands you can copy. PostHog's monorepo uses it for about 30 teams. The same files route review requests, give every agent-opened pull request one accountable owner, and send daily digests, flaky-test reports, and alerts to the right team channel.

Quick start

# billing/owners.yaml
version: 1
owners: [team-billing, '@alice']
$ uvx owners-yaml who billing/api/invoices.py
path:    billing/api/invoices.py
owners:  team-billing, @alice
status:  active
slack:   #team-billing
source:  billing/owners.yaml

Why use it

A single CODEOWNERS file works well for small repos. In a large monorepo it has these problems:

  • Line order changes the result. The last matching line wins, so a broad pattern added at the bottom can take over the specific lines above it.
  • One busy file. Every team edits the same file, so it gets merge conflicts and nobody feels responsible for it.
  • No lint. Nothing reports a pattern that matches no files, a team that no longer exists, or a directory that nobody owns.
  • Nothing but owners. You can't say that code is generated or vendored, or which Slack channel a team uses.

owners.yaml addresses these:

  • The nearest file wins, field by field. A child file that only sets owners keeps the status of its parent. inherit: false stops inheritance.
  • Rules stay in their file. A rules: pattern can only change paths below its own file, so a new rule can't take over another team's paths.
  • "Unowned" is a decision. owners: null marks code that nobody owns on purpose. Everything else without an owner shows up in owners unowned.
  • A lifecycle status. status: generated, vendored, or deprecated lets a review bot skip generated files without its own ignore list.
  • A team channel registry. The root file maps a team to its Slack channels, with a separate channel for automation and a per-bot opt-out.
  • One resolver for every tool. Review bots, CI jobs, and alerts all ask the same resolver, so none of them reimplements the resolution steps. It comes as a Python library, a CLI, and a JSON entrypoint that needs only PyYAML.

Install

uv tool install owners-yaml   # or: pipx install owners-yaml
uvx owners-yaml --help        # run it once without installing

The package installs two identical commands, owners and owners-yaml. An unrelated project on PyPI is named owners, so always write owners-yaml when you install or run it: uvx owners fetches that other package. After uv tool install owners-yaml, the owners command on your PATH is this one. In CI, pin the version (owners-yaml==0.3.0), because a new release can change how paths resolve.

Requirements: Python 3.10 or later, PyYAML, and click. The commands read tracked files through git. Outside a git worktree, pass --repo-root and they read the files from disk. lint --live needs the GitHub CLI, signed in.

Usage

Write ownership files

The smallest file sets version and owners:

version: 1
owners: team-billing

A file can override its own subtree with rules:. Every rule that matches a path applies in file order, and each one replaces only the fields it sets:

version: 1
owners: [team-billing, team-payments]
rules:
  - match: 'generated/**'
    status: generated
  - match: 'vendor/'
    owners: null

additions names the owners of additions below a directory, separate from the owners of the files already in it. It never changes owners, so a directory can name owners of additions while the unowned files below it still show up as unowned:

# products/owners.yaml
version: 1
owners: []
rules:
  - match: '/*'
    additions: team-architecture

Resolve the new directory itself, not a file inside it: owners resolve --json products/new-thing returns "additions": ["team-architecture"], and a deeper path does not. The owners of additions from every file on the walk add up, so a nested file cannot drop what an ancestor declared. The format only names these owners. A review bot or CI check decides from the change set what counts as an addition and what to do with the list.

The root file can also hold repository settings:

version: 1
owners: []
github_org: acme
producers: [review-bot]
alias_files: [package.yaml]
teams:
  team-billing:
    slack: '#billing'
    notifications:
      review-bot: '#billing-reviews'

alias_files names the other files that count as ownership files, such as a package manifest that already lists owners. Only the owners field of such a file is read, and an owners.yaml next to it wins. The default is [product.yaml], so a repository that never declares the setting still reads its product manifests. A declared list replaces the default, and alias_files: [] turns alias files off. A reader that fetches files over a network should set alias_files: [] in a repository that has no alias files, so it does not probe two names per directory.

SPEC.md lists every field and the full resolution algorithm. For editor completion, point your YAML language server at owners.schema.json.

Look up owners

$ owners resolve --json billing/vendor/stripe.py web/app.ts
{
  "billing/vendor/stripe.py": {
    "additions": [],
    "owners": [],
    "slack": null,
    "source": "billing/owners.yaml",
    "status": "active"
  },
  "web/app.ts": {
    "additions": [],
    "owners": [
      "team-platform"
    ],
    "slack": "#team-platform",
    "source": "owners.yaml",
    "status": "active"
  }
}

resolve also reads paths from stdin, one per line. Add --purpose notifications to get the channel where automation posts. When a team maps notifications per producer, as in the settings example above, name the automation as well: --purpose notifications --producer review-bot. Without --producer, a per-producer mapping is skipped and the answer falls back to the team's slack channel. A team that sends every bot to one channel writes a plain channel string instead (notifications: '#billing-alerts'), which answers any producer.

To list what nobody owns, run owners unowned. Paths under owners: null are left out.

Lint in CI

$ uvx owners-yaml==0.3.0 lint
⚠ coverage: 0 of 5 tracked file(s) resolve to unowned

✓ owners.yaml lint passed (1 warning(s))

lint fails on schema errors, a directory with two ownership files, owners.yaml files in reserved locations, and a rule that names a directory without the trailing /. Write docs/ for a directory, because docs also matches a file called docs. It warns about rule patterns that match no tracked file, and it reports coverage. Pass the changed ownership files as arguments to check only those.

Plain lint does not know which teams exist, so it accepts a slug for a team that was renamed or deleted. lint --live checks each team slug and @handle against the GitHub organization in github_org, or in --org. It asks GitHub through the GitHub CLI, which must be signed in with an account that can read the organization's teams.

Run lint before you trust a lookup. A file that who or resolve cannot parse counts as absent, so they answer from the parent directory instead of reporting the problem (SPEC section 4, step 3).

owners fmt shows where files could be merged or split without changing any resolution. It never writes.

Call it from other tools

From Python:

from owners_yaml import OwnersResolver

resolution = OwnersResolver().resolve("billing/api/invoices.py")
resolution.owners  # ['team-billing', '@alice']

The names exported from the top-level owners_yaml package are the public API. Submodules can change between minor releases.

From any language, pipe paths to the JSON entrypoint. uvx fetches the package from PyPI, so the machine needs only uv:

echo "billing/api/invoices.py" | uvx --from owners-yaml==0.3.0 python -m owners_yaml --repo-root path/to/repo

The entrypoint imports only PyYAML. A tool that already has the source can run it with no install: PYTHONPATH=path/to/packages/owners-yaml python3 -m owners_yaml.

It prints one JSON object keyed by path, in the same shape as owners resolve --json. --repo-root lets a tool resolve against a directory that holds only the ownership files, such as a sparse fetch.

Resolve without a checkout

The resolver reads ownership files through a source, so it does not need a worktree. Implement read(path) on anything that can return a file's text: a repository API, an unpacked archive, or a dict in tests. read returns None when the file does not exist.

from owners_yaml import OwnersResolver

files = {
    "owners.yaml": "version: 1\nowners: [team-platform]\n",
    "billing/owners.yaml": "version: 1\nowners: [team-billing]\n",
}


class DictSource:
    def read(self, path: str) -> str | None:
        return files.get(path)


resolver = OwnersResolver(source=DictSource())
for path, resolution in resolver.map(["billing/api/invoices.py", "web/app.ts"]).items():
    print(path, resolution.owners)
billing/api/invoices.py ['team-billing']
web/app.ts ['team-platform']

A source that pays per read, such as one that fetches over the network, can also implement read_all(paths). map() calls it twice before it reads any file: once for the root owners.yaml, which names the alias files, then once for the whole batch's ownership files. To prefetch yourself instead, ask ownership_file_paths(paths) for the same list.

Export to CODEOWNERS

Some tools read only CODEOWNERS. owners codeowners -o CODEOWNERS.generated writes the owners of every test file in CODEOWNERS syntax, for test analytics that attribute a failing test to a team. The export covers test files only and never writes .github/CODEOWNERS.

Add the commands to your own CLI

Each command is a plain click command named owners:<name>, such as owners_yaml.cli:cmd_lint. A click-based CLI can register them directly. PostHog's hogli does this in its hogli.yaml:

owners:
  owners:lint:
    click: owners_yaml.cli:cmd_lint
    description: Validate owners.yaml files

Compared with other tools

GitHub CODEOWNERS Kubernetes/Prow OWNERS code_ownership owners-yaml
Files One central file One per directory Per directory, per file annotation, or per package One per directory
Resolution Last matching line in the file Owners of all parent files are combined One ownership source per file Nearest file, field by field
Patterns inside a file Yes Yes, regex filters Yes, in the central config Yes, limited to the file's subtree
Required approvals Yes, GitHub enforces them Yes, through Prow Through the CODEOWNERS file it generates No, routing only
Coverage check No No Yes Yes
Lifecycle status No No No Yes
Team channel registry No No Team config files Yes, Slack
Generates CODEOWNERS n/a No Yes, the whole repo Test files only

Other tools cover parts of this. codeowners-validator lints a CODEOWNERS file. codeowners-generator builds one from files spread across the repo. Backstage and other service catalogs track ownership per service, not per path.

The resolver reads ownership files only. CODEOWNERS is an export target, never a second input: the two formats resolve differently, and a CODEOWNERS file carries owners and nothing else, so an answer would depend on which file a tool found. Moving an existing CODEOWNERS file into owners.yaml files is a one-time migration, by hand today.

When not to use it

  • You need GitHub to block merges on an owner's approval. Keep that in .github/CODEOWNERS. owners.yaml routes, it doesn't block. The CODEOWNERS export covers test files only.
  • Your repo is small. A CODEOWNERS file of a few dozen lines is easier to read than many small files.
  • You use GitLab or Bitbucket code owner approvals. The tool reads GitHub team slugs and @handles, and the live lint uses the GitHub API.
  • You need a stable 1.0 API. The package is at version 0.x. The file format is at version 1, but the Python API can still change between minor releases.

Project

The package lives in the PostHog monorepo. It has no dependencies on the rest of the monorepo. Run its tests with uv run --no-project --with pyyaml --with click --with pytest pytest packages/owners-yaml/tests.

To release, bump version in pyproject.toml, add the matching section to CHANGELOG.md, merge, then tag master:

git tag owners-yaml-v0.3.0 && git push origin owners-yaml-v0.3.0

The tag starts publish-owners-yaml.yml. It checks that the tag matches the version, builds and tests the wheel, and publishes to PyPI with trusted publishing. It then creates a GitHub release from the changelog section. To retry a failed run, dispatch the workflow on the same tag: gh workflow run publish-owners-yaml.yml --ref owners-yaml-v0.3.0.

MIT licensed.

Metadata

Release files for owners-yaml 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for owners-yaml 0.3.0
File Size Uploaded
owners_yaml-0.3.0.tar.gz 77.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for owners-yaml 0.3.0
File Interpreter ABI Platform
owners_yaml-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 127.2 kB

Release files / owners_yaml-0.3.0.tar.gz

Download URL owners_yaml-0.3.0.tar.gz
Size 77.9 kB
Tags Source
SHA-256 checksum
How to use checksums
10f0dc424be5c1a13351550ea3e7144e1eb622a8a7d8eb37d818b51c8eb2ac61
BLAKE2b-256 checksum
How to use checksums
7156365c38742e333ed7564467cc71959843e487a18c082e63d2c523590b93bb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / owners_yaml-0.3.0-py3-none-any.whl

Download URL owners_yaml-0.3.0-py3-none-any.whl
Size 49.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
06ecc0769fb62db1ef2f242bc6db1ce7d66ef35d9f5a7195cc973ada506a8617
BLAKE2b-256 checksum
How to use checksums
8f04d9dadc3899dbb2578b0ad11e174aae489d67a17749a5b84593798fdc5e27
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page