Thank you for considering contributing to {{project-name}}!
Either Nix -- nix develop gives every tool below at the versions flake.lock pins, uv on
nixpkgs' Python {{python_min}} (its own downloads off) -- or, installed yourself:
- uv 0.11.21, exactly:
pyproject.toml'srequired-version, which writes and checks the lock.uv sync --lockedthen installs the dev group -- ruff, basedpyright, ty, vulture, pytest and its plugins -- at the pinned versions. - just -- the command runner the automation is written in.
- ShellCheck, shfmt and
jq -- the shell's linter and formatter (its style is
.editorconfig's), and whatjust shell-checkreads the justfile's recipes with. - actionlint -- lints the workflows (and, through
ShellCheck, their
run:scripts). - dprint -- formats the Markdown and TOML.
- cocogitto (
cog) -- enforces Conventional Commits. - Nix for the last of
just checks, the flake's checks (just nix): without it,just checksstops there, every other check passed.
pip-audit and git-cliff need no install: just audit and just changelog run them from their
own dependency groups, through uv.
On NixOS, the wheels' executables (ruff, ty, git-cliff, basedpyright's node) need a standard
dynamic loader: programs.nix-ld.enable = true;.
- Fork this repository and create your branch from
main. - Clone your fork, and install the environment:
git clone https://github.com/<you>/{{project-name}} && cd {{project-name}}
uv sync --locked- Optionally,
just install-hooks:.githooks/then checks the lock, ruff, the shell, the workflows and dprint before each commit, the message as a Conventional Commit, and every pushed commit's message before a push -- what CI would refuse, minutes earlier. They needjust,uv,shellcheck,shfmt,jq,actionlint,dprintandcogonPATH(insidenix develop, with Nix);git config --unset core.hooksPathturns them off.
Before you start, read AGENTS.md. It documents the lint and type policy -- which rules are on, why, and what to do when one fires -- and applies to human and AI-assisted contributions alike.
-
Pure functions: isolate logic from side effects wherever possible and cover it with unit tests.
-
Regressions: if you are fixing a bug, include a test that reproduces the previous failure state.
-
Coverage: every line and branch (
fail_under = 100). Code a test cannot reach is questioned before it is excluded. {%- if project_kind == "workspace" %} -
Where logic goes: in
packages/{{project-name}}-core, not in the command line, which parses arguments and prints. {%- endif %} -
The tests import the package as installed (pytest's
--import-mode=importlib): the editable install here, the built one in the flake's checks -- so a file missing from the wheel fails there. -
Property tests (Hypothesis), if you add them:
uv add --dev 'hypothesis==<version>', at the flake's nixpkgs' version, and aconftest.pyat the repository's root -- every run loads it; add it to[tool.basedpyright]'sinclude-- loading Hypothesis's ownciprofile:"""Hypothesis's `ci` profile in every run, the Nix sandbox's included.""" from hypothesis import settings settings.load_profile("ci")
It draws the same examples each run, with no example database and no deadline; Hypothesis loads it by itself only under a CI's variable (
CI,GITHUB_ACTIONS...), so the flake and a local run would draw anew.
- The Conventional Commits specification is enforced on every commit. A commit's subject is its changelog entry -- git-cliff writes the changelog from them at each release -- so write it for the package's users.
- Run the checks. The
justfileruns the workflows' commands:
just checks # the lock, ruff, the shell, the workflows, types, dead code, tests, dprint, cog, pip-audit, nix
just nix # `nix fmt -- --ci`, then the flake's checks, as nix.yml: git's files only
just format # ruff (imports, format), shfmt, the justfile and dprint in place, then `nix fmt`
just build # every package's sdist and wheel, into dist/just alone lists every recipe.
A new dependency: uv add it. The flake builds with nixpkgs' package of the same name; just nix
names one it cannot map -- another name in nixpkgs, extras, a marker -- to give in
nix/packages.nix's overrides. A new dev tool: uv add --dev 'name==<version>', the flake's
nixpkgs' version (its pins check holds it); a Python package there -- a pytest plugin, say --
joins the flake's test environment too.
- Make sure
just checkspasses. - Open a pull request against
main, titled as a conventional commit: a squash merge makes the title its commit, and so a changelog entry (CI checks it). Describe the problem it solves, and link its issue (Fixes #123), if any. - Wait for a maintainer's review.
{% if project_kind == "workspace" -%}
Each package is released on its own, from a clean main equal to origin/main, CI green on it:
just changelog <package> --unreleased # the section the release will write
just release <package> <major|minor|patch>The tag is <package>-v<version>. Release {{project-name}}-core first when {{project-name}}
needs its new version, and raise {{project-name}}'s requirement on it to that version: uv and the
flake build against the workspace's copy, so nothing here sees a stale floor -- PyPI's users would.
The release holds to this, along the whole chain a member needs: it refuses a member it needs,
directly or through another, whose src or installed fields (dependencies, scripts...) changed
since its tag, or whose release is not yet on PyPI (its publish.yml run failed, or every file
yanked); a requirement between them that excludes or sits below the release it names; and a
library's version a dependent's range excludes. It asks PyPI's Simple API; RELEASE_INDEX_URL names
another index.
{%- else -%}
From a clean main equal to origin/main, CI green on it:
just changelog --unreleased # the section the release will write
just release <major|minor|patch>The tag is v<version>.
{%- endif %}
The recipe shows every step, in order, and runs no tests -- CI's verdict on that commit is the
release's: it refuses a release with nothing in it, sets the version (uv version --bump, the lock
with it), writes the changelog (git-cliff), commits, tags, and pushes the commit and the tag
together. The first release is the version pyproject.toml declares, whatever the level. The tag
runs publish.yml: the package built once, published to PyPI by Trusted Publishing, then its GitHub
release of the same files, its notes the changelog's new section (a dev or pre-release version, a
GitHub pre-release)
{%- if project_kind != "lib" and container_image %};
the main package's, image.yml too (its OCI image to GHCR, from the flake's image: nix build .#image builds it here){% endif %}.
- The commits since the last tag must hold a change a user meets (
feat,fix,perf,build...):chore,ciandstylealone make no release. - The release pushes to
maindirectly: with "Require a pull request before merging" on, the maintainer cutting it needs the rule's bypass. A refused push leaves the commit and the tag local -- push them again withgit push --atomic origin main <tag>once allowed.
The dev group's pins are the flake's nixpkgs' versions, so a tool moves with nixpkgs:
nix flake update, then nix flake check -- its pins check names each pin to move, to the
version it gives -- then uv lock and just checks. A new ruff or basedpyright brings new rules;
read them before silencing any.