Contributing
This file follows the release standard for the maintainer’s Python packages, and oxyscraper is its reference repo. How is oxy versioned, documented and released? records each decision and the evidence for it. Epistole departs from it in one place: its Python versions. A repo that adopts the standard follows the one-time setup in oxyscraper’s copy.
Setup
You need uv and the GitHub CLI.
uv sync
uv run prek installprek install writes a pre-commit hook, which runs the linters, and a commit-msg hook, which checks the message format. The tests run in CI, not in a hook.
Pull requests
Every change reaches main through a pull request, the maintainer’s included. CI then runs before the change lands.
git switch main && git pull
git switch -c <branch>
git commit
git push
gh pr create --fill
gh pr merge --auto --squashgit commitruns thepre-commitandcommit-msghooks. A hook that fixes a file stops the commit, so rungit add -Aand commit again.gh pr create --fillcopies the message of a single commit into the pull request. With several commits, pass--titleyourself.gh pr merge --auto --squashmerges the pull request once the required checks pass.gh pr checks --watchshows them.
Pull requests merge by squash only, and the squash commit takes the pull request’s title and description. Since release-please reads that commit:
- The title follows Conventional Commits. The
titlecheck runs thecommit-msghook fromprek.tomlagainst it. - A breaking change ends the description with a one-line
BREAKING CHANGE:footer that links to its section of the upgrade page. - To correct a merged entry, edit the merged pull request’s description. Put the corrected message between
BEGIN_COMMIT_OVERRIDEandEND_COMMIT_OVERRIDE, and release-please uses it on its next run.
Pull requests from bots
- Dependabot opens
chore: bump the uv groupandci: bump the actions groupon Mondays. prek-update.ymlopenschore: update prek hookson the first of each month.- release-please opens
chore(main): release X.Y.Z, and it updates that pull request after each merge that users would see.
Their types start no release, so merge the first two with gh pr merge <number> --auto --squash once they pass. The release pull request waits until you want to release.
What the rulesets block
- The
mainruleset blocks every push tomain, and a merge beforeall-greenandtitlepass. The admin can still merge a failing pull request with Merge without waiting for requirements to be met, so keep that for emergencies. - The
version tagsruleset lets only the App and the admin create, update or delete av*tag.
Versions
Versions follow SemVer. Below 1.0, a new minor always means a breaking change.
| Commit | Changelog | Bump below 1.0 |
|---|---|---|
feat, fix, perf, docs, deps, revert |
visible | patch |
refactor, test, build, ci, style, chore |
hidden | none on its own |
any type with !, or a BREAKING CHANGE: footer |
visible | minor |
bump-minor-pre-major and bump-patch-for-minor-pre-major in release-please-config.json set the last column. No commit reaches 1.0 on its own. Leaving 0.x takes a Release-As: 1.0.0 footer, and the policy for after 1.0 is set then.
Breaking changes
The pull request that makes a break also writes its migration into user_guide/upgrading.qmd, under the next minor’s heading, such as ## 0.4. Every break before a release goes into the same next minor, so the heading is known when the pull request opens. Below 1.0, a break needs no deprecation period.
CI
ci.yml runs on each pull request and each push to main:
testruns on Ubuntu, macOS and Windows, on every supported Python, against the built wheel.next-pythonruns the next CPython from its first beta, and it may fail.lowestruns the floor Python with each dependency at its floor.lintruns every prek hook on every file.docsbuilds the site. Epistole’s examples send mail, so the build runs none of them.all-greenpasses when the jobs above pass. Themainruleset requires only this job andtitle, so a change to the jobs never touches the ruleset.
pytest turns warnings into errors, so a new upstream deprecation fails the Dependabot pull request that brings it in.
Tests
Tests are in tests/, and uv run pytest runs them. CI runs them against the built wheel, so test_packaging.py fails a change that drops py.typed from it.
Annotate every test parameter, including fixtures and parametrize values. Ruff never enforces this, because ruff.toml turns off ANN under **/tests/**. Pyrefly does enforce it, because preset = "strict" reports an unannotated parameter as implicit-any-parameter. pyrefly.toml sets no exception for tests. That difference is deliberate. An annotated fixture lets pyrefly check the test body against the real type, so a misspelled field name fails pyrefly check before the tests run.
Skip the return-annotation part of ANN. -> None on every test function adds no information.
Dependencies
Each floor in pyproject.toml is as low as the lowest job proves. A floor rises only in a deps: commit, which the changelog shows to users. Dependabot moves uv.lock and the pinned actions weekly, after a 7-day cooldown. Its chore and ci prefixes keep those pull requests out of the changelog. Dependabot does not read prek.toml, so prek-update.yml updates the hooks monthly.
Python versions
Epistole supports every CPython from 3.13 on that has not reached its end of life. The standard starts at the oldest CPython still supported, and three facts, measured on 2026-09-29, hold Epistole at 3.13:
typing.overrideneeds 3.12, and 8 modules use it.mimetypes.guess_file_typeneeds 3.13.- The address check needs the strict
email.utils.getaddressesfrom the CVE-2023-27043 fix. 3.11 gained it only in 3.11.10 and 3.12 only in 3.12.6, andrequires-pythoncannot exclude the earlier patch releases without one!=for each.
The rest of the policy is the standard’s:
- A version joins at its final release. Add its classifier in
pyproject.tomland its entry in thetestmatrix, and pointnext-pythonat the version after it. - A version leaves in the first release after its end of life, in a
feat!:commit. Raiserequires-python,.python-version, ruff’starget-version,default_language_versioninprek.tomland the Python of thelowestjob. Then remove the version’s classifier and its matrix entry.
Releasing
- release-please keeps a release pull request open with the next version,
CHANGELOG.mdanduv.lock. - Merging it makes the App create the tag and the GitHub release.
release.ymlbuilds the distributions, and itspypijob waits for approval. On the run’s page, click Review deployments, tickpypi, then click Approve and deploy. The job then uploads the distributions to PyPI with attestations.docs.ymldeploys the site.
A release published by hand starts the same two workflows, which makes it the recovery path.
When something fails
- A CI job fails.
gh pr checksnames the job, andgh run view <run-id> --log-failedprints its log. Push a fix to the same branch, and auto-merge stays on. - The title check fails. Fix the title with
gh pr edit --title, and the check runs again. - A changelog entry is wrong after the merge. Use
BEGIN_COMMIT_OVERRIDE, as Pull requests describes. release.ymlordocs.ymlfails after the tag exists.gh run rerun <run-id> --failedruns the failed jobs again.
Traps
- Never pass
release-typetorelease-please-action. The action then ignoresrelease-please-config.json, and it prints no warning. - The
uv.lockJSONPath inrelease-please-config.jsonreads@.name.value, not@.name. release-please parses each TOML value into an object, and without.valueit updates nothing. - An edit to the release pull request’s body is lost when
mainmoves before the merge, and it never reachesCHANGELOG.md. Migration notes go in the upgrade page. GITHUB_TOKENcannot run CI on a pull request it opens, and a release it creates starts no workflow. So release-please andprek-update.ymluse the App’s token.- The
pypiandgithub-pagesenvironments acceptv*tags only, and both release workflows run on the release’s tag. - A numpydoc
Returnsblock starts with a:line. griffe, which great-docs uses, reads a bare description line as the return type.
Prose
The package has two spellings in prose. Each means one thing.
- Epistole is the name. Use it in running text: “Epistole sends the same message through any backend.”
epistoleis the identifier, in code formatting. Use it for the distribution, the module, and the command:uv add epistole,import epistole.
Never write bare “epistole” in a sentence. Never write any other variation, such as “EPISTOLE” or “epistole.py”.