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. 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
ownerskeeps thestatusof its parent.inherit: falsestops 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: nullmarks code that nobody owns on purpose. Everything else without an owner shows up inowners unowned. - A lifecycle status.
status: generated,vendored, ordeprecatedlets 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.
In CI, pin the version (owners-yaml==0.2.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:. Within one file, the last matching rule wins:
version: 1
owners: [team-billing, team-payments]
rules:
- match: 'generated/**'
status: generated
- match: 'vendor/'
owners: null
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": {
"owners": [],
"slack": null,
"source": "billing/owners.yaml",
"status": "active"
},
"web/app.ts": {
"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.
To list what nobody owns, run owners unowned. Paths under owners: null are left out.
Lint in CI
$ uvx owners-yaml==0.2.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, and owners.yaml files in reserved locations.
It warns about rule patterns that match no tracked file, and it reports coverage.
lint --live also checks each team slug and @handle against the GitHub organization in github_org, or in --org.
Pass the changed ownership files as arguments to check only those.
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.2.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.
When not to use it
- You need GitHub to block merges on an owner's approval. Keep that in
.github/CODEOWNERS.owners.yamlroutes, 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.2.0 && git push origin owners-yaml-v0.2.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.2.0.
MIT licensed.
Metadata
Release files for owners-yaml 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| owners_yaml-0.2.0.tar.gz | 70.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| owners_yaml-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 117.4 kB
Release files / owners_yaml-0.2.0.tar.gz
| Download URL | owners_yaml-0.2.0.tar.gz |
|---|---|
| Size | 70.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9df634a6111468a16f958960e8b77895410c2d1a9a5357984ecaefc317cddd11
|
|
BLAKE2b-256 checksum How to use checksums |
a7a5c54cad19e66657d9191edfd4a85114ed01e97a85be64a757d64d233d2ac7
|
| 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.2.0-py3-none-any.whl
| Download URL | owners_yaml-0.2.0-py3-none-any.whl |
|---|---|
| Size | 46.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ec1c6eac83dd4284c713ffad97d72cf465b6c9c0d99814136092476808ca92fd
|
|
BLAKE2b-256 checksum How to use checksums |
1365609768d38c62850383fedc218b6577f133d7e83f87c8cc0f06b136acee6c
|
| 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}
|