Contributing¶
This page is about working on reference-service itself:
what to run before you push, how the documentation is kept true, and the
commit convention the release depends on. The service's design is in
Architecture; the reasoning behind each
decision is in the decision records.
Before you push¶
Set the project up once:
uv sync && uv run pre-commit install
.python-version pins the interpreter to 3.13, so uv sync uses the same
one continuous integration does. There is no separate setup step and no
drift between your machine and the pipeline. pre-commit install wires
up both hook stages: pre-commit (formatting, linting, secrets) and
commit-msg (the commit convention below).
Development is test-driven: write the failing test first, then the implementation. Testing strategy describes the tiers and what each one is for.
Then, before every push:
just check
That is lint, typecheck, imports, test and precommit, followed by
git diff --exit-code — two pre-commit hooks can modify files, and the
diff turns a hook that changed the tree into a loud failure instead of a
silent pass on the second run. It needs no Docker and finishes in a couple
of minutes. CI's check job runs exactly this.
Before a pull request that touches an adapter, a generated file, the API contract or observability:
just check-all
That is check, then docs-build, test-integration, gates,
o11y-gates and contract-gates — the six gates CI runs as separate
jobs (check, docs, integration, gates, o11y-gates and
contract), in one local command. It needs Docker: the integration tier
starts real containers, and the contract gate runs oasdiff in one.
just security is the other check worth running before a pull request that
touches a dependency or a Dockerfile. It builds every image this project
ships, audits uv.lock with pip-audit, scans each image with Trivy and
writes a software bill of materials per image — the same four recipes
CI's security job runs. It needs Docker, and it is deliberately not
part of just check-all: its result changes without a commit, because an
advisory can be published overnight against a version that is already
locked. security can turn red on a re-run of an unchanged branch — that
is normal, and the runbook
says what to do with it. Supply chain
describes each step.
Documentation ships with the change¶
Four independent mechanisms enforce this, and each catches something
different. All four run in .github/workflows/ci.yml, and each has a
just recipe or a script you can run locally.
scripts/check_docs_updated.py is a hard gate: CI's docs-freshness
job. It fails a pull request that changes src/ without touching docs/,
README.md or mkdocs.yml. It is a blunt heuristic, on purpose: it cannot
tell a stale sentence from a fresh one, it only notices that source changed
and no documented surface did. A pure refactor or a dependency bump will
trip it, and that is the accepted cost of catching the case that matters.
The escape hatch is the no-docs-needed label on the pull request —
the workflow re-runs when a label is added, so applying it turns the failed
check green without an empty commit. A label is used rather than a commit
message trailer because pull requests are squash-merged, which rewrites the
message. Reproduce a red check locally with:
python3 scripts/check_docs_updated.py origin/main HEAD
scripts/check_docs_freshness.py only warns; nothing it finds can fail
a build. It is what just docs-freshness runs, and what CI's
docs-warnings job calls. It reports two things: a page whose covers:
front matter names a path that changed in this pull request while the page
itself did not, and a page whose last_reviewed date is more than 180 days
old. Pages under docs/adr/ are skipped: a decision record does not go
stale. Where the hard gate above only notices that some source changed
and no documentation did, this one names the page and the exact path
that moved.
lychee and mkdocs build --strict are hard gates on links, not on
prose. --strict fails the build on a link to a page that no longer
exists, a renamed heading anchor, or an unresolvable include; just
docs-build runs it, and so do CI's docs job and docs.yml before a
deploy. lychee does the equivalent check for links leaving the site,
against the real internet, in its own links job — kept separate from
the site build so a network blip reads as a network blip, not as a broken
site. just links runs the same check locally, given a lychee binary,
against the same lychee.toml.
scripts/check_doc_examples.py is a hard gate on behaviour rather than
prose: it runs every curl example marked <!-- exec --> against a real,
running service. just docs-examples starts the compose stack in its own
Compose project, runs the script, then tears the stack down whether it
passed or failed. The script runs every example and fails if any does not
succeed. Where the other three mechanisms ask "did the right files change
together", this one asks "does the documented example still work" — a
stale sentence is a nuisance, but a curl example that 404s is a reader
concluding the service itself is broken. It is not part of just
check-all; it runs as its own docs-examples job in CI, against a
stack that job starts itself.
Front matter¶
Every page under docs/ opens with YAML front matter, and the freshness
script reads two keys from it:
---
last_reviewed: 2026-09-14
covers:
- justfile
- src/reference_service/settings.py
---
last_reviewed is the date someone last read the page against the code;
set it when you write or review a page. covers: lists the paths the page
describes, relative to the project root, so a change to one of them without
a change to the page can be reported; a directory prefix covers everything
under it. Leave covers: out when a page tracks no particular file.
The local loop¶
just docs-install
just docs
That serves a live preview on http://127.0.0.1:8001 and rebuilds as you save. Before pushing:
just docs-build
Adding a page means adding it to nav: in mkdocs.yml. A page absent from
the navigation is unreachable.
The <!-- exec --> marker¶
A fenced block opts into check_doc_examples.py by putting the literal
line <!-- exec --> on its own line, immediately before the block's
opening fence:
<!-- exec -->
```bash
curl -si http://localhost:8000/healthz
```
Most fenced blocks in this documentation are not runnable — a file's contents, a fragment of output, a command that would modify the reader's own machine — so the marker is opt-in rather than "every bash fence": that keeps the runnable set small enough to trust, instead of a wall of exclusions for everything that is not meant to run.
A marked block must satisfy two rules. The check's own execution model takes care of the first one; the second is not enforced by anything and has to be got right by hand:
- Self-contained. Each marked block genuinely runs as its own
bash -euo pipefailinvocation, with nothing carried over from an earlier block — no shared shell variable, no earliercd. A block that depends on state a previous example left behind fails on its own, since that state was never there to begin with. - Assert something; do not merely run something.
curlon its own exits0on an HTTP error response — a 404 or a 500 is still a "successful"curlinvocation as far as the shell is concerned, and the check only looks at the shell's exit code. A marked block has to check the response itself and fail the shell if it does not match, the way the examples in Getting started capture the response and thengrep -q ... <<< "$response"against the status line and body.curl -fis the single-command version of the same rule when only the status code matters.
If every <!-- exec --> block is ever removed from docs/, the check
fails on purpose: "no examples found" is treated as a bug — the marker
was renamed, or the examples were deleted — never as a silent pass. A new
runnable example anywhere under docs/ needs the marker and has to
satisfy both rules above. Do not make one depend on seeded data: the ids
just up seeds differ per machine, and just docs-examples starts its
stack without the seed.
just docs-freshness is not CI's docs-freshness job¶
These share a name and run different scripts, which makes it easy to reach for the wrong one while reproducing a red pull request.
The CI job named docs-freshness runs scripts/check_docs_updated.py —
the hard gate above. The just docs-freshness recipe runs
scripts/check_docs_freshness.py — the advisory warnings above, which is
what CI's separate docs-warnings job calls. Reproduce a red
docs-freshness check with the python3 command above, not with just
docs-freshness. That recipe runs the other script, never fails, and will
tell you nothing about why the hard gate is red.
When the warnings become failures¶
Not yet, and not on their own. Flipping check_docs_freshness.py's two
warnings to hard failures needs all three of the following to be true first,
checkably:
- Every published page carries
covers:wherever a genuine coupling exists. A warning that never fires because nothing declares the coupling is not evidence the check works — it is evidence nobody wrote the front matter yet. - A month of pull requests has passed with the warnings producing no noise that nobody acted on. Verified by reading the pull requests, not assumed.
- The team has agreed to retire
check_docs_updated.pyin the same change. Keeping both as hard gates means one pull request can fail twice for the same reason, under two different names, with two different fixes.
The switch is deliberate and not taken here, because a large refactor trips path coupling across many pages at once, which lands exactly when a team is busiest.
Conventional Commits are required¶
Every commit message and every pull request title must follow Conventional Commits:
<type>[optional scope][!]: <description>
Types: feat, fix, docs, refactor, test, perf, build, ci,
chore, style, revert. Imperative mood, lowercase, no trailing full stop.
docs: explain the shutdown deadline mismatch
fix(api): return 422 instead of 500 for mixed currencies
feat!: require APP_ENVIRONMENT to be set explicitly
This is not a style preference. release.yml derives the next version
number and the changelog from commit history, so a non-conforming message
silently breaks the release. Because pull requests are squash-merged,
the pull request title becomes the commit on main — so the title is what
that automation actually reads. A ! marker has a second reader: the
contract gate treats a commit range with one as a declared breaking change
— see The API contract.
The commit-message hook is installed by uv run pre-commit install — see
Before you push — which wires up both the pre-commit
and commit-msg stages.
The hook runs Commitizen, and Commitizen comes from uv.lock, not from a
version pinned in the hook configuration: the hook's command is uv run
--locked cz, so the tool that checks a message is the same version that
later decides the release. commitizen is pinned exactly, in the dev
group of pyproject.toml, and that one pin is the only place its version
lives — Dependabot updates it there. uv sync installs it; otherwise uv
run installs it on first use, which makes the first commit slower than it
needs to be. One pin, in one file Dependabot updates, is the whole point —
see ADR 0014.
Two recipes preview what the release will do, without doing it:
| Command | What it does |
|---|---|
just changelog |
The changelog entry the next release would write, from the Conventional Commits since the last tag. Read-only. |
just next-version |
The version the next release would choose. Read-only. Before the first release there is no tag to count from, and the recipe says so: the first run of release.yml tags the version on disk as it is. |
Repository settings¶
Six settings live in the GitHub interface, not in this repository, and
the workflows need them: the Pages source, a no-docs-needed label, a
RELEASE_TOKEN secret when a ruleset on main requires pull requests,
or for the weekly template update to push workflow changes and run CI,
the Actions permission to open pull requests when that secret is absent,
squash-merge, and making the published container packages public after
the first release. The README.md lists all six under Continuous
integration and releases, with what goes wrong when each is missing.
The weekly template-update.yml is described in Update from the
template.