adr-check
Checks the architecture decision records (ADRs) in a repository's docs/adr/ directory. It keeps
the records uniform, links superseded decisions to their replacements, and, in CI, stops anyone
rewriting a decision that has already been merged.
An ADR is a short Markdown file that records one significant decision and the reasons for it. The format this tool checks is a small variant of MADR: Michael Nygard's sections plus a list of the options considered.
What it checks
Files in the ADR directory that are not ADRs do not fail the check. It skips README.md and any
file that does not end in .md, and it ignores subdirectories. Every other Markdown file is
checked, so a badly named ADR fails instead of disappearing. A symlink in place of an ADR is an
error, because the check would otherwise validate the file it points at.
In the working tree
These checks need only the files in front of you. They run in both modes.
- Filename.
NNNN-kebab-title.md: four digits, then lowercase words joined by hyphens, for example0007-use-managed-identities.md. - Frontmatter. A YAML block between
---lines holding exactly four keys, and no others:status: one ofproposed,accepted,rejectedorsuperseded.date: an ISO date such as2026-03-12.supersedes: a list of ADR numbers, such as[7], or empty.superseded_by: one ADR number, or empty.
- Title. An H1 of the form
# 0007. Title, where the number matches the filename. - Sections. Four H2 headings:
## Context,## Considered options,## Decisionand## Consequences. Headings inside code fences and HTML comments do not count. - Supersession links. They must agree in both directions. If
0009supersedes0007, then0007hasstatus: supersededandsuperseded_by: 9. An ADR withsuperseded_bymust havestatus: superseded, and asupersededADR must name its successor. A link to a missing ADR, to the ADR itself, or to a rejected successor fails. - Duplicate numbers. Two files may not share a number. A
supersedesorsuperseded_bythat names a number two ADRs share also fails, because it is ambiguous.
Against the base branch
--mode ci adds checks that compare your branch with the branch it merges into. Merged decisions
are a record of what the team knew at the time, so they stay put.
- Merged ADRs are immutable. The body of an ADR on the base branch may not change, and the
frontmatter may change only from
acceptedtosupersededwithsuperseded_byset. A pull request labelledadr-correctionmay fix a typo or a link in the body. The label does not allow a frontmatter change. proposedADRs may change. AproposedADR on the base branch is not settled, so these rules do not protect it.- No deletions or renames. ADRs are never deleted, and never renamed. The one exception is the
renumbering repair. If two branches took the same number, one ADR may change its filename number
and its H1 number, and nothing else. ADRs that point at it may then update their
supersedesorsuperseded_byto the new number. - No reused numbers. A new ADR may not take a number the base branch already uses, including one the base took after you branched.
- Symlinks on main need an override. A symlinked ADR already on the base branch cannot be
repaired through this check. Replacing it with a regular file reports a type change, and deleting
it reports a deletion. Neither is allowed, and
adr-correctionlifts neither, so a person must override the required check for that pull request. - No
proposedon the main branch. With--merging-to-main, any ADR withstatus: proposedfails.proposedexists only on a branch.
Usage
Pick the form that suits the repository. All three run the same checks. Replace the placeholder versions with a current release.
Pre-commit hook
The repository ships a pre-commit hook. Add it to
.pre-commit-config.yaml:
repos:
- repo: https://github.com/agentic-delivery-au/adr-check
rev: vX.Y.Z
hooks:
- id: adr-check
The hook runs when a file under docs/adr/ changes. It passes no filenames, because the rules look
across the whole directory. It sets language_version: python3.14, because the package needs Python
3.14 or newer and pre-commit would otherwise build it on its own interpreter. The machine needs a
python3.14 executable on PATH.
If you would rather install a released package than clone this repository, use a local hook:
repos:
- repo: local
hooks:
- id: adr-check
name: adr-check
entry: adr-check
language: python
language_version: python3.14
additional_dependencies: [adr-check==X.Y.Z]
files: ^docs/adr/
pass_filenames: false
The hook checks the working tree only. It cannot see the base branch, so it cannot catch an edit to a merged ADR, a deleted file or a number that the base branch already holds. CI catches those.
In CI
Run the package from PyPI with uv. The checkout must hold the full history, because the check compares your branch with its base:
uvx adr-check==X.Y.Z --mode ci --base-ref origin/main --labels "$LABELS" --merging-to-main
On GitHub, the composite action wraps that command and fills in its inputs from the event:
name: adr-check
on:
pull_request:
types: [opened, reopened, synchronize, edited, labeled, unlabeled]
permissions:
contents: read
jobs:
adr-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: agentic-delivery-au/adr-check@vX.Y.Z
Pin the action to a release tag, @vX.Y.Z. Move it together with the hook's version (its rev:, or
adr-check== in a local hook), so the hook and CI apply the same rules. Four details matter:
fetch-depth: 0. A shallow checkout has no base branch to compare with, and the action fails with a message that says so.labeledandunlabeled. Without them, addingadr-correctiondoes not re-run the check, and the pull request stays red until the next push.- No
paths:filter. A filtered workflow does not report on a pull request that touches no ADR, so a required check never arrives. - Avoid triggering on
push. A push carries no pull request labels, so a history comparison would fail every mergedadr-correction. If a push does trigger the action, it runs only the checks on the files and theproposedcheck.
The action installs the tool from its own checkout (the files the runner fetched for uses:), so
the code that runs is the commit the tag points at. Rulesets in this repository stop anyone moving
or deleting a release tag (vX.Y.Z), and stop anyone but the release App creating one.
Repository settings gives their limits. The action uses
astral-sh/setup-uv to provide uv.
Action inputs
Every input has a default taken from the github context. You normally set none of them.
| Input | Default | Purpose |
|---|---|---|
adr-dir |
docs/adr |
Directory that holds the ADRs |
base-ref |
origin/<base branch> on a pull request, empty on a push |
Ref to compare with. A push ignores it |
merging-to-main |
true for a pull request into the default branch or a push to it, else false |
Whether status: proposed fails the run |
labels |
The pull request's label names, as JSON | Labels on the PR. adr-correction allows a body change |
Set merging-to-main yourself only to override the default, for example in a repository whose
release branch is not the default branch.
Command line
adr-check [--mode {local,ci}] [--adr-dir DIR] [--base-ref REF] [--labels LABELS] [--merging-to-main]
| Flag | Default | Purpose |
|---|---|---|
--mode |
local |
local checks the working tree. ci also compares with the base ref |
--adr-dir |
docs/adr |
ADR directory |
--base-ref |
origin/main |
ci only. Ref to compare with. It needs full history |
--labels |
none | ci only. PR labels as a JSON array or a comma-separated list |
--merging-to-main |
off | Fail any ADR with status: proposed |
The command prints one line per problem to stderr. It exits 0 when every rule holds, 1 when a rule is broken and 2 when the base ref is missing or the directory is not in a git repository.
In ci mode, the no-reused-numbers check compares with the tip of the base ref. The other history
checks compare with the merge base of the base ref and HEAD. Git finds renames with
git diff --name-status -M, which detects a rename only when the file is mostly unchanged. A
renumbering repair always is.
Development
Install uv, then:
uv sync # create .venv and install the package with its dev dependencies
pre-commit install # one-time, sets up the pre-commit and commit-msg hooks
uv sync also installs an interpreter if no suitable one is present. requires-python is a lower
bound rather than a pin, so 3.14 is the floor and uv may select something newer. A project that
needs an exact interpreter pins it in a .python-version file, which uv reads.
| Task | Command |
|---|---|
| Run the tests | uv run pytest |
| Lint | uv run ruff check . |
| Format | uv run ruff format . |
| Type check | uv run pyrefly check src |
| Run the tool | uv run adr-check --help |
| Everything the hooks check | pre-commit run --all-files |
Tests and coverage
uv run pytest runs the suite with coverage already on, so the command above and the one
tests.yml runs in CI are the same run. Coverage is measured over src/ and reported to
Codecov. pytest never fails on a coverage number. The targets live in
codecov.yml: the project must not regress by more than 1%, and a pull request's changed lines need
80% coverage.
Type checking names the path, pyrefly check src, so it skips tests/ (deliberately unannotated)
and scripts/ (repository tooling that the template owns and Ruff also skips).
Repository settings
scripts/bootstrap_repo.py applies the branch protection, merge settings and Actions permissions
that release-please needs. It prints a plan and changes nothing until you pass --apply. Read the
plan first, because it replaces an existing ruleset wholesale.
python3 scripts/bootstrap_repo.py # what it would change
python3 scripts/bootstrap_repo.py --apply # change it
Two more rulesets protect release tags (v*.*.*). "protect release tags" blocks moving or deleting
one, with no bypass. "release tags come from the release bot" blocks creating one, except by the
release App. The App's key is a secret of the release environment, which deploys only from main,
so only the release-please job on main can mint the token here. That holds only while no other
secret holding the same key reaches a workflow: an organisation secret shared with this or another
repository would let that workflow mint the token too. A repository admin can also change or disable
either ruleset. Pin the action to a commit SHA if you need a guarantee that rests on neither. The
script does not manage these rulesets. Floating tags such as v0 stay movable.
release-please.yml authenticates as a GitHub App, because a pull request opened with
GITHUB_TOKEN triggers no workflows and the required checks would never report on the release PR.
Create an App with Contents, Issues and Pull requests write permissions, install it on the
repository, then store its client ID as the variable RELEASE_BOT_CLIENT_ID and its private key as
the secret RELEASE_BOT_PRIVATE_KEY of a release environment whose deployment branches are
limited to main. There is deliberately no fallback to GITHUB_TOKEN.
Releases
release-please reads the Conventional Commits landed
on main and keeps an open release PR that bumps the version and updates CHANGELOG.md. Merging
that PR tags the version and publishes the GitHub release. The publish job in release-please.yml
then builds the package and uploads it to PyPI.
The release type is python, so release-please bumps the version in pyproject.toml, and an
extra-files entry keeps the project's own version in uv.lock in step. Without it the release PR
would fail uv sync --locked. The filter matches the package by name (adr-check), so rename the
package and it has to change in the same commit.
Tags have no component prefix (v0.2.0, not adr-check-v0.2.0), because include-component-in-tag
is false.
Publishing
The publish job uses trusted publishing, so no PyPI
token is stored. Register a trusted publisher on PyPI for this repository with the workflow name
release-please.yml and the environment pypi, and create a pypi environment on GitHub. Renaming
either side breaks the match.
Licence
MIT.
Metadata
Release files for adr-check 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| adr_check-0.1.1.tar.gz | 60.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| adr_check-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 76.9 kB
Release files / adr_check-0.1.1.tar.gz
| Download URL | adr_check-0.1.1.tar.gz |
|---|---|
| Size | 60.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
54132c2ac16355c3149a236cc192ce7f08b421ef45021de5cdaf9e03860e50ab
|
|
BLAKE2b-256 checksum How to use checksums |
be7aff802a65de31603614f47c24dd6c7d6452ec651bdb77f933b7e6805b50b8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.
Transparency logRelease files / adr_check-0.1.1-py3-none-any.whl
| Download URL | adr_check-0.1.1-py3-none-any.whl |
|---|---|
| Size | 16.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
362204ed2c805d7274dfc2dbd0365b1637801487ae5da9634da55234cb3f4548
|
|
BLAKE2b-256 checksum How to use checksums |
e24aa587496665c2a0eababe49203d396bdcfb598e51b52d442a4f001a3bb0f7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.
Transparency log