Contributing¶
Repository layout¶
pyfr/
docs/ this site: about PyFr itself
superpowers/ design specs and plans; not published
cookiecutter.json hooks/ the template's prompts and hooks
{{cookiecutter.project_slug}}/ the template body — the source of truth
docs/ mkdocs.yml a generated project's own site
scripts/ the documentation hygiene scripts
examples/reference-service/ rendered from the template; never edited by hand
scripts/regen.py the regeneration loop: regen, regen-check, adopt
scripts/check_site_links.py the links between the two sites, and into the repository
src/pyfr_cli/ the updater a generated project runs: `pyfr update`, `pyfr update-check`
updates/ migration scripts, one directory per template release that needs one
tests/ PyFr's own tests: the hooks, the render, the golden diff, pyfr-cli
mkdocs.yml pyproject.toml this site and the root toolchain
justfile repository commands — see Commands below
.github/workflows/ continuous integration, the full suite, publishing (GHCR and PyPI)
.github/dependabot.yml automated dependency updates
Two documentation sites live here, and the split is by subject. The root
docs/ describes PyFr: how to generate a project, how the template is
built and tested, and why it is a template. Everything about the service
a project runs — the getting started walk-through, the runbook, the
guides, the reference pages, the service's decision records — is in the
template body's docs/, because every generated project ships that site
about itself. Working on the documentation
has the mechanics.
Two scripts live at the root: scripts/regen.py, the regeneration loop,
and scripts/check_site_links.py, which only this repository needs
because only it builds two sites into one. The three documentation
hygiene scripts — check_docs_updated.py, check_docs_freshness.py and
check_doc_examples.py — live in the template body's scripts/, with
their tests in its tests/unit/, because a generated project runs them
in its own continuous integration. PyFr runs the example's rendered
copies over its own pages; Documentation ships with the
change says which job runs which.
The repository root and the reference service are two separate Python
projects, each with its own pyproject.toml, and they are never synced
together. The root project holds the documentation toolchain, the template
toolchain — cookiecutter, the hooks' and generation tests, the
regeneration script — and one package: pyfr-cli, the updater a generated
project runs as just update, published to PyPI by every release
(Working on pyfr-cli).
Working on the template¶
From the repository root:
uv sync --group dev && uv run pre-commit install
Edit {{cookiecutter.project_slug}}/, then:
just regen
That renders the template with tests/reference-answers.yaml into
examples/reference-service/ and is the only way that directory changes.
It also removes anything in examples/reference-service/ that the template
does not produce — a stray untracked file included — except uv.lock and
git-ignored paths such as .venv/, so keep scratch files out of that tree.
just regen-check (CI's golden job) fails the build when the two
disagree, naming the files. Then run the example's own gates:
cd examples/reference-service && just check
The example's just precommit steps aside inside this repository — the
root's .pre-commit-config.yaml owns the hooks here, and just precommit
at the root runs them over every tracked file.
The template also ships a generated project's .github/ — ci.yml,
nightly.yml, release.yml, docs.yml, template-update.yml and
dependabot.yml. In this
repository their render at examples/reference-service/.github/ is output
and inert: GitHub runs workflows from a repository's root only, and the
root's own workflows test the example through working-directory. Edit
them in the template. The generation tests parse every render's
workflows, check that each just recipe a step calls exists in that
render's justfile, and that a pruned backend leaves no word behind in
them; tests/test_regen.py holds every uses: ref in the template's
workflows equal to the root's. The workflows themselves run only in a
generated project.
The full-suite tests¶
The generation tests prove every render is well-formed. Three renders are
also proven to be services: tests/test_generated_service.py renders
everything on (the reference answers), everything off, and PostgreSQL
alone, for real — the post-generation hook runs git init, uv sync and
the first commit, exactly as on a user's machine — and runs each project's
own just check-all, the six gates its CI runs as separate jobs. The two
smaller combinations also change the name, organisation, licence and port,
so those substitutions are proven to produce a working project too. Three
syncs and three full gate runs take too long for every push, so the tests
carry the full_suite marker, pyproject.toml deselects it by default,
and .github/workflows/full-suite.yml runs them on every merge to main,
nightly, and on demand — one job per combination. Locally, with Docker
running:
just test-full-suite # all three, a few minutes each
just test-full-suite postgres-only
A failing gate fails the test with that gate's output, and the render is
kept on disk (the test prints where); a passing render is removed, since
each carries a .venv/ of a few hundred megabytes.
The service's own checks before a pull request — just check, just
check-all and just security — are the example's, and the example's site
describes them in its
Contributing
page. Run them from examples/reference-service/.
Generated files¶
Three files in the template body are produced by tools, and the tools run
in the example, not in the template: openapi.json by just openapi,
.env.example and docs/reference/configuration.md by just config-docs.
After a template change that affects one of them — a route, a schema, a
setting — the round trip is: just regen, run the tool in
examples/reference-service/, then bring the result back into the
template. just adopt does that when the tool replaced lines; when it
inserted lines (a new setting, a new endpoint), copy them into the template
file by hand, re-inserting the substitutions the render resolved —
{{ cookiecutter.project_slug }} for the contract's title, for
instance. just regen-check then confirms the two agree.
Backends and pruning¶
Three prompts choose which backing services a generated project gets:
database (postgres or none), cache (redis or none) and
object_storage (s3 or none). Each list's first entry is cookiecutter's
default, so a --no-input render is the "everything on" project — the same
shape as examples/reference-service/. Whichever combination is chosen, an
unchosen backend must leave no trace: no dependency, import, setting,
compose service, recipe, page or file for it, and the render still passes
ruff check and ruff format --check.
Two mechanisms enforce that, at different granularities. hooks/
post_gen_project.py's PRUNED table deletes whole files and directories
that belong entirely to one backend — migrations/, src/<package>/
infrastructure/db/, and so on — after cookiecutter has rendered everything
else. Jinja {%- if %} blocks remove lines inside files that mix a
backend with other content — pyproject.toml, compose.yaml, settings.py,
container.py, justfile, .importlinter, .env.example, mkdocs.yml
and the documentation pages among others. A path belonging to one backend
is always deleted by the hook, never emptied by Jinja.
Every conditional in a Python, YAML, TOML or Markdown file uses
cookiecutter's left-strip block tags — {%- if %}, {%- elif %},
{%- else %}, {%- endif %} — each on its own line, never the trim or
right-strip forms. There are two exceptions. The first is a single token
inside one line of a shell recipe in the justfile — a recipe parameter
default, an image name in a for loop — written inline as
{% if … %}…{% endif %} with no dashes, because splitting that line would
change the command; a whole recipe, or anything in a Python, YAML or TOML
file, always takes the own-line form. The second is inside one sentence
of a Markdown page, where a word or phrase differs by backend and the
line cannot be split without breaking the sentence: there the trim forms
{%- if … -%} / {%- else -%} / {%- endif -%} (both dashes, so no
newline survives on either side of the branch) are allowed, and each use
carries a Jinja comment — {#- … #}, which renders as nothing — naming
why the line could not be split. There is one such use today: the
observability reference's sentence on the removed instrumentation
dependency, whose ending is either a full stop or a pointer to
instrument_redis. Prefer a {%- set %} phrase variable, described
below, wherever the varying part can be named.
A {%- tag consumes the newline and any whitespace before it, so a
block's own leading blank line has to sit inside the block, never
before the tag, or the "everything on" render loses a blank line the
pruned render never had — and that render must stay byte-identical to
examples/reference-service/. The mirror case has one
sanctioned exception: where a pruned block must leave a blank line
behind — a bullet list whose first item is conditional, and Markdown
needs a blank line between the paragraph above and whichever item comes
first — the block ends with {%- else %} followed by a plain {% endif %}
with no dash, whose kept newline is that blank line. It is used once, in
the runbook's "Act, per dependency" list; comment it wherever it recurs.
For example:
import pytest
{%- if cookiecutter.cache == "redis" %}
from redis.asyncio import Redis
{%- endif %}
Two rules follow from this: a conditional wraps a line that already exists in the everything-on render — it introduces no new logic — and it wraps only whole existing lines, moving no blank line before the tag it belongs after. In a Markdown page the same rule means a conditional paragraph starts with a blank line inside the branch; a conditional table row or list item has no blank line at all, because one would end the table.
{%- else %} is the exception, for the rare spot where the pruned render
needs a reduced line rather than none at all: an import list that loses
one name (from tests.compose_images import compose_image,
dockerfile_base_image becomes from tests.compose_images import
compose_image), or container.py's close_container, whose nested
try/finally cannot be dedented by Jinja, so the branches with the
database or the cache off repeat the surrounding try/finally one level
up instead — a four-way {%- if %} … {%- elif %} … {%- elif %} … {%- else %}
block, not new control flow.
Where a sentence enumerates things that vary by backend — a list of
package names, a comma-separated set of dependencies — nesting {%- if %}
blocks inside the sentence produces a page nobody can read. Build the
list in a {%- set %} variable at the top of the page or table instead,
and join it where the sentence needs it. docs/explanation/layers.md
does this for the import-linter table: a {%- set _forbidden = ["FastAPI",
"Starlette"] + (["SQLAlchemy", "asyncpg"] if cookiecutter.database ==
"postgres" else []) + … %} line above the row, and {{ _forbidden |
join(', ') }} in the row. The set line renders as nothing — its {%-
consumes the newline before it, and its plain %} keeps the line's own —
so it takes the place of one line and moves no blank line; the leading
underscore marks the variable as the page's own. This is also the tool
for a sentence whose wording varies by backend: set the phrase once at
the top, and the sentence stays one plain line. The /readyz example in
the HTTP API reference and in the README is built that way, from a
_checks string and a _deps list set under the heading above it, and
needs no exception to the own-line rule.
To see what one combination renders, skip the hook's side effects with
PYFR_REGEN=1 and pass the answers that differ from the defaults:
PYFR_REGEN=1 uv run --group dev cookiecutter . --no-input -o /tmp/x database=none cache=none
tests/test_generation.py renders all eight combinations on every push
(test_a_render_carries_only_the_backends_it_chose), running ruff check
and ruff format --check on each, plus an AST-based resolver for
first-party imports that catches a conditional pruning a def while a
test still does from … import of it — ruff has no way to know the name
stopped existing, so it stays quiet, and pytest would only discover the gap
while collecting the test. The same test reads the pages that document
what a render has — the index, getting started, the runbook, the
commands, configuration, observability and supply-chain references, the
container guide, the glossary and mkdocs.yml — and fails on a word of a
pruned backend in any of them. Comparative prose elsewhere, such as a
decision record that weighs PostgreSQL against an in-memory store, is
deliberately not policed. After any template edit, just regen then
git diff --stat examples/reference-service coming back empty is the proof
that the everything-on render did not move. just regen-check run after
just regen compares the freshly regenerated example against itself and
would not catch a lost byte; run against the committed example instead, it
is the golden diff CI runs, and that does catch one.
Dependabot and the template¶
Dependabot's directory entries point at examples/reference-service/
because it cannot parse Jinja. Its pull requests therefore change the
rendered example, not the template. The golden job runs just adopt
first on those pull requests, and .github/workflows/adopt.yml commits
the same result to main after the merge. adopt copies a replaced
line back into the template file that renders it; a change of any other
shape — an inserted hook, a new dependency — fails with the file name,
and you make it in the template by hand. If main is ever red on
golden after a Dependabot merge — adopt.yml lost a race with another
merge, or its rebase conflicted — run just adopt, commit and push.
Action versions go the other way. Dependabot's github-actions ecosystem
reads /.github/workflows only, so its bumps land in this repository's
own workflows, and just adopt copies each bumped uses: ref into the
template's workflows and regenerates the example. tests/test_regen.py
fails when a template workflow pins an action at a different ref than the
root does — or uses one the root does not — so every action a generated
project runs is one Dependabot sees here. Between a ci(deps) merge and
adopt.yml's commit, another pull request's docs job can fail that
test — its merge ref has the new root pins and the old template pins;
re-run it once the adopted commit is on main.
Working on pyfr-cli¶
src/pyfr_cli/ is the updater every generated project runs as just
update — uvx --from pyfr-cli@latest pyfr update — and the one package
this repository publishes. The design is the M8 specification in
docs/superpowers/specs/;
the decision is ADR 0018.
Three things about it differ from the rest of the root tooling:
- Its version is the template's.
pyproject.toml'sversion,cookiecutter.json's_template_versionand the git tag move together incz bump, andrelease.yml'spublish-pypijob uploads the wheel with PyPI Trusted Publishing once the tag is pushed. Never edit the version by hand and never publish by hand:just updaterelies on the newest release on PyPI being the newest template tag. - It is typed and tested harder than the tooling.
just typecheckrunsmypy --strictover it and is part ofjust lint;tests/cli/holds its unit tests;tests/test_update_e2e.pybuilds a two-version template in a temporary directory and runs real updates through it, with no network.just wheelbuilds the wheel and runspyfr --versionfrom it, as CI'sdocsjob does. - It must work for every version a project can record. A project
generated at v0.7.0 has an answers file and nothing else. The tool
installs
.pyfr-update-ignorewhen it is missing, and every error message states a cause and a fix, one line each (errors.py); the ones about the answers file and thetemplatebranch point at the guide each project carries,docs/guides/update-from-template.md. When you change what the tool needs from a project, the end-to-end test's oldest scenario is the one to extend first.
Writing a migration script¶
Most template changes reach a project through the merge. A change a merge
cannot express — a file that moves, a setting that changes shape — ships a
script under updates/<version>/, where <version> is the tag of the
release that needs it: before.py runs on the project's tree before the
merge, after.py after the merge is committed. Either may be absent.
updates/README.md
is the contract; in short:
- Standard library only. The scripts run with
uv run --no-project python, so nothing from the project's environment is installed first. - Idempotent — running twice equals running once. A failed update is re-run, and a script that already did its work exits 0 without doing it again. Check before you act.
before.pymoves things so the merge lines up (git mvthroughsubprocess);after.pyrewrites contents once the template's version of a file is in place. The tool commits what each leaves —git addany file you create, because the commit stages tracked files only.- Exit non-zero to stop the update. Whatever the script writes to stderr is shown to the user.
- A deleted file is not an error. Teams delete example files; a script that finds its target missing exits 0.
Test one the way tests/test_update_e2e.py tests its fixture scripts:
generate at the previous version, edit the project the way a team would,
update, assert the tree. updates/ sits outside the template body, so a
script is never rendered into a project.
Working on the documentation¶
Two sites, one deploy¶
This repository builds two MkDocs sites and deploys them as one.
- PyFr's site is the root
docs/with the rootmkdocs.yml, built intosite/and served athttps://emadmokhtar.github.io/pyfr/. Its pages are this one, the home page, getting started, the roadmap, the glossary, PyFr's four decision records (0002, 0003, 0017 and 0018) and Why a template, not a framework. - The reference service's site is
examples/reference-service/docs/with that project'smkdocs.yml, built intosite/reference-service/and served athttps://emadmokhtar.github.io/pyfr/reference-service/. Both are rendered from the template body, so a page there is edited in{{cookiecutter.project_slug}}/docs/and regenerated. Every generated project builds the same site about itself and deploys it with its owndocs.yml.
just docs-build builds both, in that order, with mkdocs build
--strict; .github/workflows/docs.yml runs the same recipe and uploads
site/ as one Pages artifact. The second build runs inside
examples/reference-service/ with that project's own toolchain and its
own mkdocs.yml, and sets four environment variables the template's
mkdocs.yml reads with !ENV: SITE_URL, so the rendered site_url is
the nested address; REPO_URL and REPO_NAME, so the header's
repository link and name are PyFr's, not the reference service's; and
EDIT_URI, so each page's edit link opens the template page —
edit/main/{{cookiecutter.project_slug}}/docs/, with the braces
percent-encoded so the URL is valid — because the rendered example is
never edited by hand. A generated project leaves all four unset — its
mkdocs.yml reads site_url: !ENV [SITE_URL, "<its own Pages address>"]
and the same shape for the other three — and gets a site at the top of
its own domain, linked to its own repository. mkdocs build --strict
turns a warning into a failure: a link to a page that no longer exists, a
renamed heading anchor, or an unresolvable include each fails the build
rather than printing a warning nobody reads.
A link within a site is relative, as usual: roadmap.md,
../explanation/layers.md. A link between the two sites is an
absolute URL, always: from a root page to a service page it is
https://emadmokhtar.github.io/pyfr/reference-service/<page>/, and from a
template page to a root page it is
https://emadmokhtar.github.io/pyfr/<page>/. MkDocs' --strict fails a
relative link to a page outside the site it is building, so there is no
other way to write one. The root lychee.toml excludes PyFr's own Pages
host from the external-link check for the same reason: a link to a page
added in the branch being checked resolves only after the deploy. What
checks those links instead is scripts/check_site_links.py, the last step
of just docs-build: it reads every https://emadmokhtar.github.io/pyfr/…
link out of both documentation trees and resolves it against the site/
directory just built — the page must exist there, and an anchor must be an
element id in it — so a renamed page or heading on either side fails the
build before it reaches the deploy. Given --repo-root, as the recipe
passes it, the same script also reads every link into this repository's
tree on GitHub — edit/main/…, blob/main/… and tree/main/… under
https://github.com/EmadMokhtar/pyfr/ — out of the built HTML of both
sites and checks that the path exists in the checkout. That covers the
edit links Material writes from edit_uri, which lychee.toml skips
because a page added in the branch exists on main only after the merge,
and the decision records' links into docs/superpowers/, which it skips
for the same reason.
A page in the template's docs/ carries the project's identity, never the
reference service's: {{ cookiecutter.project_slug }},
{{ cookiecutter.project_name }}, {{ cookiecutter.package_name }} and
{{ cookiecutter.github_org }} where the project is named, and PyFr's own
URLs literal. tests/test_generation.py renders the default answers and
fails on reference-service, reference_service, Reference Service or
emadmokhtar in any rendered page, the README, the scripts, mkdocs.yml
or the workflows, outside PyFr's two URLs — which is also why a template
page can never link to the reference service's site.
Front matter¶
Every published page opens with YAML front matter. last_reviewed is the
date someone last read the page against the code; covers: lists the
paths the page describes, so a change to one of them without a change to
the page can be reported. At the root the paths are repository-relative
(scripts/regen.py, hooks/post_gen_project.py). In the template's pages
they are relative to the generated project (justfile,
src/{{ cookiecutter.package_name }}/settings.py), because that is where
the freshness script checks them. The script asks git for the changed
paths with --relative, so they come back relative to the directory it
runs in: the root's docs/ when PyFr runs it from the root, the
project's docs/ when a generated project runs it from its own root.
The local loop¶
just docs-install
just docs
That serves a live preview of PyFr's site on http://127.0.0.1:8000 and
rebuilds as you save. For the reference service's site, run just docs in
examples/reference-service/ instead; it serves on port 8001, so the two
previews can run together. Before pushing:
just docs-build
Adding a page means adding it to nav: in the right mkdocs.yml. A page
absent from the navigation is unreachable. In the template's mkdocs.yml
a nav entry for a page that is only about one backend sits inside that
backend's {%- if %} block.
docs/superpowers/ is an archive¶
It holds the design specification and the milestone implementation plans. They
stay in the repository — they are the reasoning behind what this site
describes — but mkdocs.yml excludes them from the published site. They are
working documents, thousands of lines long, and out of date by design once a
milestone lands.
Do not treat editing one as documenting a change. The freshness checks below deliberately do not count it.
Documentation ships with the change¶
Four independent mechanisms enforce this, and each catches something
different. The scripts are the template's, run from their rendered copies
under examples/reference-service/scripts/; a generated project runs the
same scripts over itself, with the defaults. Here every option names
PyFr's own trees.
check_docs_updated.py is a hard gate: CI's docs-freshness job.
It fails a pull request that changes {{cookiecutter.project_slug}}/src/
without touching the root docs/, README.md, mkdocs.yml, or the
template's docs/ or README.md. The job runs
python3 examples/reference-service/scripts/check_docs_updated.py \
--source '{{cookiecutter.project_slug}}/src/' \
--docs docs/ --docs README.md --docs mkdocs.yml \
--docs '{{cookiecutter.project_slug}}/docs/' \
--docs '{{cookiecutter.project_slug}}/README.md' \
--exclude-docs docs/superpowers/ "$BASE_SHA" HEAD
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.
check_docs_freshness.py only warns; nothing it finds can fail a
build. CI's docs-warnings job runs just docs-freshness "$BASE_SHA"
HEAD, which runs the script twice — at the root over PyFr's own docs/,
then inside examples/reference-service/ over the rendered pages:
uv run --group docs python examples/reference-service/scripts/check_docs_freshness.py \
--exclude docs/superpowers/ origin/main HEAD
cd examples/reference-service && \
uv run --group docs python scripts/check_docs_freshness.py origin/main HEAD
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.
The second run is what checks the template's pages here: a change to the
template body regenerates them, so their covers: coupling is reported
in this repository too, not only in a generated project's own CI.
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; CI's
docs job runs just check, which starts with just docs-build over
both sites. 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. It reads the root docs/, the root README.md, and the rendered
example's docs/ and README.md: the template body's Markdown carries
Jinja inside its URLs, so the render is what a link checker can read. just
links runs the same command locally, given a lychee binary.
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. Those examples live in the template's pages, so this
check runs where the service is: CI's docs-examples job runs just
docs-examples inside examples/reference-service/, which starts the
compose stack, runs the script and tears the stack down whether it passed
or failed. The root pages carry no marked examples, and the script fails
on a tree with none — "no examples found" is treated as a bug — which is
why PyFr never runs it over the root docs/. How to mark an example, and
the two rules a marked block must satisfy, are on the reference service's
Contributing
page.
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 check_docs_updated.py — the
hard gate above. The just docs-freshness recipe runs
check_docs_freshness.py — the advisory warnings above, which is what
CI's separate docs-warnings job calls. Reproduce a red
docs-freshness check locally with the python3 command quoted above,
with origin/main HEAD as its two refs — 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 frontmatter 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 script is the template's, so the switch would land in every generated project too. That is a reason to be slow about it, not a reason to skip the three conditions.
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(template): prune the cache's readiness probe with the cache
feat!: require APP_ENVIRONMENT to be set explicitly
This is not a style preference. Release automation 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.
The commit-message hook is installed by uv run pre-commit install at the
repository root — see Working on the template
above — which wires up both the pre-commit and commit-msg stages.
The hook runs Commitizen, and Commitizen comes from the root uv.lock,
not from a version pinned in the hook configuration: the hook's command is
uv run --locked --group dev cz, run from the repository root, so the tool
that checks a message is the same version that later decides the release. Run
uv sync --group dev at the repository root once to install it (just
docs-install does too, since dev is a default group); 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.
A generated project carries its own Commitizen (spec M7-9): commitizen
in its dev group, a [tool.commitizen] table in its pyproject.toml,
the same commit-message hook, and just changelog / just next-version.
Its version, tags and releases are its own; the root's copy decides PyFr's
releases only. In this repository the example's copy is rendered output,
and the root's hook is the one that checks your messages.
The release moves one more version. cookiecutter.json's
_template_version — the value every generated project records in its
.pyfr-answers.yml — is written by the same cz bump as
pyproject.toml's version (version_files in [tool.commitizen]), and
just regen runs as that bump's pre-bump hook, after the write and before
the commit, so the example's .pyfr-answers.yml records the released
version in the bump commit. Between releases the two versions are equal by
construction, and tests/test_generation.py fails the pull request that
edits one by hand; cz bump --check-consistency in release.yml refuses
to release if they have drifted anyway. A generated project's
_template_version is therefore the tag its template body was released
under — what just update reads to bring it up to date.
One-time repository settings¶
Seven settings live in the GitHub interface and one on pypi.org, not in
this repository, so they are easy to miss when standing up a fork. The repository's default
workflow-token permission is not one of them: every workflow that writes
declares the permission it needs in its own permissions: key, which
GitHub honours whatever the repository default says — a project generated
from this template released from a fresh repository left at the default
"Read repository contents" setting, pushing its tag and publishing its
images with the workflow token alone.
- Settings → Pages → Source = "GitHub Actions". Without it the
Docsworkflow's build job succeeds while its deploy job fails with an opaque error, and every published documentation link stays dead. - A
no-docs-neededlabel must exist, or the freshness check has no escape hatch. - Squash-merge as the merge strategy, since the commit convention above
assumes the pull request title becomes the commit on
main. - A
RELEASE_TOKENsecret, so the release push lands. Themainruleset requires a pull request and exempts only the repository admin. The defaultGITHUB_TOKENis not exempt, sorelease.yml's push of the bump commit and tag is refused (GH013) and every release fails at that step with no tag reaching origin — and on a user-owned repository GitHub refuses to add the Actions app to the bypass list, so the only route is a token whose owner is exempt. Create a fine-grained personal access token as the admin (repository access: this repository only; Contents: read and write) and store it as the repository secretRELEASE_TOKEN. The workflows hand it to their push step alone, through that step's environment — the checkout keeps no credential, so nothing the job runs before the push can read it. Renew it before it expires; the failure mode when it lapses is the sameGH013at the push step. adopt.ymlpushes tomainwith the sameRELEASE_TOKEN, for the same reason.- After the first release, make the two GHCR packages public. The first
publish-imagesrun createsreference-serviceandreference-service-migrationsas private packages:docker pullfails for anyone outside the repository, and the nightlysecurityjob can scanlatestonly because it authenticates with the workflow token. Use the package's "Change visibility" setting, under the package's own settings, once for each of the two. Nothing inrelease.ymlcan do this. The names changed in M7 — an image is named for the generated project, and the reference answers name itreference-service. - Enable Dependabot alerts and Dependabot security updates. These are
the "Dependabot alerts" and "Dependabot security updates" toggles under
the repository's security settings.
.github/dependabot.ymlis the version updates schedule and works without either. Alerts are what tells you about a new advisory between two weekly runs, and security updates are what opens a pull request for it the same day rather than at the next weekly run. - A pending publisher on pypi.org for
pyfr-cli, before the first release. Projectpyfr-cli, ownerEmadMokhtar, repositorypyfr, workflowrelease.yml, no environment.release.yml'spublish-pypijob uploads with Trusted Publishing — PyPI accepts a short-lived token GitHub mints for the run — so no PyPI token is stored. Without the publisher that job fails and every other release job still succeeds; it runs last and nothing depends on it. A fork publishes under its own package name, or not at all:pyfr-cliis this repository's.
The first release run was not like the others¶
This repository had no git tags until its first release, so that first run
of .github/workflows/release.yml could not use Commitizen's normal path:
cz bump needs a prior tag to diff against to compute the next version.
The workflow's bootstrap mode instead tagged the version already on disk in
pyproject.toml — v0.5.0, on 2026-09-11 — directly, with no bump
commit and no change to CHANGELOG.md, because there was nothing to
bump: CHANGELOG.md already described v0.5.0, generated from the commit
history that produced it.
So v0.5.0 sits on main with no accompanying version-bump commit, and
that is the correct result of the first release, not a stuck or partial
run — see the "Bump the version and write the changelog" step's own comment
in release.yml for the two ways cz bump was confirmed to fail outright
with zero tags. Every release since — v0.6.0 was the first — finds a tag
to diff against and takes the normal cz bump path, exactly as Commitizen
documents it. The bootstrap branch stays in the workflow: dead code here,
live for a copy of this repository made without its tags.
Commands¶
Run these from the repository root. The reference service has its own
justfile, documented on
its own site;
run those from examples/reference-service/. Several recipes share a
name with one of the service's — check, docs-build, links, audit,
changelog — and check different things from a different directory. They
are not interchangeable.
| Command | What it does |
|---|---|
just docs-install |
Install the documentation toolchain (uv sync --group docs). |
just docs |
Live preview of PyFr's site on http://127.0.0.1:8000, rebuilding as you save. |
just docs-build |
Build both sites into site/ with --strict, exactly as CI and docs.yml do: PyFr's at the top, and the reference service's rendered site — built from examples/reference-service/ with its own toolchain and its own mkdocs.yml, with SITE_URL set to its nested address, REPO_URL and REPO_NAME pointing its header at this repository, and EDIT_URI pointing its edit links at the template body — under site/reference-service/; then scripts/check_site_links.py resolves every link between the two sites against that tree, and every link into this repository's tree on GitHub against the checkout. |
just links |
Dead external links, via lychee, over the root docs/, the root README.md and the rendered example's docs/ and README.md. Needs the lychee binary locally (brew install lychee); CI's links job gets it from the action instead, against the same lychee.toml and the same paths. |
just test |
This repository's own tests (tests/) — the hooks' tests, the generation-test matrix that renders all eight backend combinations and checks each one, scripts/regen.py's tests, the golden diff, pyfr-cli's unit tests (tests/cli/) and the end-to-end update test — with both the dev and docs groups, because the generation tests build a render's site with the root's MkDocs. Needs no Docker. |
just test-full-suite [combination] |
The full-suite tests: three combinations rendered for real, synced, committed and run through their own just check-all. Slow; needs Docker and the network. combination is one of everything-on, everything-off, postgres-only. CI runs them on merge to main and nightly, never on a pull request — see above. |
just precommit |
The repository's git hooks over every tracked file — the reference service's own just precommit skips itself when it finds it is nested inside this repository. |
just regen |
Regenerate examples/reference-service/ from the template with the answers in tests/reference-answers.yaml; run this after every change to {{cookiecutter.project_slug}}/ and commit the result. |
just regen-check |
The golden diff: render and compare, writing nothing. CI's golden job. |
just adopt |
Copy Dependabot's edits to the rendered example back into the template, then check; only for line-for-line replacements — anything else fails with the file name and is made in the template by hand. Then, in the other direction, copy the root workflows' action pins into the template's workflows and regenerate the example — Dependabot's github-actions updates land in /.github/workflows only. |
just audit |
pip-audit over the root uv.lock — the documentation and release toolchain — with the same flags as the reference service's own audit. CI's security job runs both. |
just lint |
just typecheck, then ruff check and ruff format --check over the root's own Python: hooks/, scripts/, src/, tests/. The template body is excluded; the example is linted by its own just lint. |
just typecheck |
mypy --strict over src/pyfr_cli/, the one package this repository publishes. Part of just lint and of just check. |
just wheel |
Build the pyfr-cli wheel and run pyfr --version from it, exactly as CI's docs job does; the version it prints must be pyproject.toml's. Needs the network. |
just changelog |
Preview the changelog entry the next release would write, from Conventional Commit history. Read-only. |
just next-version |
Preview the version number the next release would choose. Read-only — the release itself runs in CI (.github/workflows/release.yml). |
just docs-freshness [base] [head] |
The advisory warnings only: a stale last_reviewed date, or a covers: path that changed while its page did not. Runs the example's scripts/check_docs_freshness.py twice — at the root over docs/ with --exclude docs/superpowers/, then inside examples/reference-service/ over the rendered pages; defaults to origin/main HEAD. Never fails. This is not CI's docs-freshness job — see above. |
just check |
docs-build, typecheck, test and regen-check — everything CI checks at the repository level without Docker. Run before pushing a documentation, template, pyfr-cli or repository-tooling change. |
Two different servers, one port
just docs at the root and the service's just dev both default to
port 8000. Stop one before starting the other, or set APP_HTTP_PORT
to something else for the service.