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 install

prek 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 --squash
  • git commit runs the pre-commit and commit-msg hooks. A hook that fixes a file stops the commit, so run git add -A and commit again.
  • gh pr create --fill copies the message of a single commit into the pull request. With several commits, pass --title yourself.
  • gh pr merge --auto --squash merges the pull request once the required checks pass. gh pr checks --watch shows 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 title check runs the commit-msg hook from prek.toml against 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_OVERRIDE and END_COMMIT_OVERRIDE, and release-please uses it on its next run.

Pull requests from bots

  • Dependabot opens chore: bump the uv group and ci: bump the actions group on Mondays.
  • prek-update.yml opens chore: update prek hooks on 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 main ruleset blocks every push to main, and a merge before all-green and title pass. 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 tags ruleset lets only the App and the admin create, update or delete a v* 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:

  • test runs on Ubuntu, macOS and Windows, on every supported Python, against the built wheel.
  • next-python runs the next CPython from its first beta, and it may fail.
  • lowest runs the floor Python with each dependency at its floor.
  • lint runs every prek hook on every file.
  • docs builds the site. Epistole’s examples send mail, so the build runs none of them.
  • all-green passes when the jobs above pass. The main ruleset requires only this job and title, 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.override needs 3.12, and 8 modules use it.
  • mimetypes.guess_file_type needs 3.13.
  • The address check needs the strict email.utils.getaddresses from the CVE-2023-27043 fix. 3.11 gained it only in 3.11.10 and 3.12 only in 3.12.6, and requires-python cannot 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.toml and its entry in the test matrix, and point next-python at the version after it.
  • A version leaves in the first release after its end of life, in a feat!: commit. Raise requires-python, .python-version, ruff’s target-version, default_language_version in prek.toml and the Python of the lowest job. Then remove the version’s classifier and its matrix entry.

Releasing

  1. release-please keeps a release pull request open with the next version, CHANGELOG.md and uv.lock.
  2. Merging it makes the App create the tag and the GitHub release.
  3. release.yml builds the distributions, and its pypi job waits for approval. On the run’s page, click Review deployments, tick pypi, then click Approve and deploy. The job then uploads the distributions to PyPI with attestations.
  4. docs.yml deploys 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 checks names the job, and gh run view <run-id> --log-failed prints 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.yml or docs.yml fails after the tag exists. gh run rerun <run-id> --failed runs the failed jobs again.

Traps

  • Never pass release-type to release-please-action. The action then ignores release-please-config.json, and it prints no warning.
  • The uv.lock JSONPath in release-please-config.json reads @.name.value, not @.name. release-please parses each TOML value into an object, and without .value it updates nothing.
  • An edit to the release pull request’s body is lost when main moves before the merge, and it never reaches CHANGELOG.md. Migration notes go in the upgrade page.
  • GITHUB_TOKEN cannot run CI on a pull request it opens, and a release it creates starts no workflow. So release-please and prek-update.yml use the App’s token.
  • The pypi and github-pages environments accept v* tags only, and both release workflows run on the release’s tag.
  • A numpydoc Returns block 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.”
  • epistole is 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”.