Contributing¶
Everything you need to develop, test, and release this library.
See also: Usage · API Reference · the repository's root
CONTRIBUTING.mdholds the authoritative branch/PR and commit-message policy.
Setting up for development¶
poe is a dev dependency, so it exists only once the virtualenv does. Bootstrap with the shell
entry point first, then use poetry run poe for everything else:
bash bin/venv.sh # first time only: creates .venv; poe exists only after this
poetry run poe init # venv (idempotent), git hooks, repo rules, security, Pages
poetry run poe enable_pages # maintainers, one time, after the first PyPI release
If bin/venv.sh just installed Poetry into ~/.local/bin, add that directory to PATH before
poetry run works. The GitHub helpers in init (enable_pages, enable_repo_rules,
enable_security) are maintainer steps that only warn on a fork, so contributors can ignore
those warnings. enable_pages is already done for this repo.
init runs six steps, in order:
ensure_env: seed.envfrom.env.example. A library ships no.env.example, so here it only prints an info line and moves on.venv: create the Poetry virtualenv and install all dependencies, dev + docs included. Poetry is auto-installed if missing.precommit: install the git hooks.enable_pages: point GitHub Pages at thegh-pagesbranch. Until the first release creates that branch it only prints an info line, which is why a maintainer runs the third command above once more after the first release (see One-time Pages setup).enable_repo_rules: apply the GitHub repository rules.enable_security: enable the GitHub security helpers.
Tests and linting¶
poe unit_tests # poetry run pytest tests/unit/
poe integration_tests # poetry run pytest tests/integration/
poe lint # ruff + mypy + codespell + pydocstyle + shell/sql/yaml gates
CI runs the same gates on every pull request; keep them green locally before pushing.
The bin/ scripts need bash 4 or later (they use associative arrays, declare -A). macOS ships
bash 3.2 as /bin/bash, so on a Mac run brew install bash and put Homebrew's bin first on
PATH; the macOS CI jobs do the same before running the tests.
Verifying the built package¶
Before opening a release PR, confirm the wheel actually builds and imports — this catches
packaging mistakes (a missing __init__, an unshipped _internal/ subpackage) that source-tree
tests never surface:
Publishing the documentation (versioned, via mike)¶
The published site is versioned: a consumer pinned to an older release reads that
release's docs, not HEAD. mike maintains the version tree
on the gh-pages branch and MkDocs-Material renders the version dropdown.
Two workflows split the work:
| Workflow | Trigger | What it does |
|---|---|---|
Docs - Strict Build Check (docs.yaml) |
every push + PR | mkdocs build --strict only — catches broken links/nav before a release. Never deploys. |
Release to PyPI (release-pypi.yaml) |
manual release | after the PyPI publish succeeds, deploys the released version with mike deploy --update-aliases <X.Y> latest |
Because the deploy runs after publishing, the site only ever advertises versions that actually shipped. Notes:
- Granularity is
X.Y—1.4.2and1.4.7share the1.4entry; thelatestalias always tracks the newest release and is the default landing version. - Prereleases never move
latest— a version with a suffix (1.2.3rc1) builds and publishes, but the docs deploy job is skipped.
One-time Pages setup¶
mike serves from the gh-pages branch, so Pages must be set to Deploy from a branch →
gh-pages. The workflow's GITHUB_TOKEN cannot change that (it is a GitHub App token without
repo-admin rights), so do it with your own gh auth:
This already runs inside poe init (after bootstrap), and is idempotent and
non-blocking — it warns and continues if gh is absent/unauthenticated, no remote resolves,
or you are not a repo admin (a fork), so it never breaks init.
Ordering matters: the gh-pages branch does not exist until the first release deploy
creates it. Until then enable_pages deliberately leaves Pages untouched (so the site is never
pointed at an empty branch) and tells you to re-run it. So: cut the first release, then run
poe enable_pages once. Manual alternative: Settings → Pages → Build and deployment →
Source: Deploy from a branch → gh-pages / /.
Pull requests¶
- Open an issue first, from the repository's issue template, stating what should change, why, and the files it touches. Every branch and PR starts from one.
- Branch off the default branch as
<type>/<issue-number>-<slug>(feat/42-…,fix/57-…). Thebranch-has-issuecheck requires an issue number in contributor branch names;release/,dependabot/, andrenovate/branches are exempt. - Fill out the PR template completely, with
Closes #<issue-number>in the body. - Ensure the CI checks (tests, lint, docs build) pass — they are the merge gate.
Releasing¶
Releases are tag-driven and secret-free when the project is connected to a GitHub remote:
- The version is the git tag (via
poetry-dynamic-versioning);pyproject.tomlcarries the version for Commitizen. Bump it only as part of a release. Trigger a release from the Actions tab (Release to PyPI/Release to Test PyPI,workflow_dispatchwith the version), or by pushing avX.Y.Ztag. - The release workflow runs the full test suite as a hard gate, builds with
python -m build, and publishes via OIDC trusted publishing (pypa/gh-action-pypi-publish) — no token secrets are needed. Register the pending publishers below; GitHub creates therelease-pypiandrelease-test-pypienvironments on the first run. - The changelog is regenerated from tags at release/build time (
poe changeloglocally); CI never commitsCHANGELOG.mdback to the protected default branch.
Maintainer setup — trusted publisher (one time, before the first release)¶
Register a trusted publisher on both pypi.org and
test.pypi.org. Every claim must match the workflow exactly or the upload
fails with an opaque invalid-publisher:
| Claim | Value |
|---|---|
| Owner / repository | your GitHub <owner> / <repo> |
| Workflow filename | release-pypi.yaml (PyPI) / release-test-pypi.yaml (Test PyPI) |
| Environment | release-pypi / release-test-pypi |
| PyPI Project Name | must equal the distribution name (name in pyproject.toml) |
The GitHub Release step exists only in release-pypi.yaml, not in the Test PyPI workflow, so a change there is never rehearsed; diff the two workflows when editing either.
For the very first upload the project does not exist yet — register a pending publisher at the account level (not under an existing project's settings). Publishing from a laptop instead of CI is the one case that still needs an API token; OIDC works only from GitHub Actions.
Choosing publish targets¶
The scaffold wires the release workflows for the official public registry (PyPI) and a staging
registry (Test PyPI). To publish to a private / non-official source instead — a git source
(pip install git+https://…), a private PEP 503 index, or (for ecosystems that support it) GitHub
Packages — wire the consumer-side source in pyproject.toml with an explicit-priority guard
against dependency confusion (poetry's priority = "explicit"; pip --index-url, never
--extra-index-url).
Branding the docs site¶
The scaffold ships a placeholder brand image at docs/assets/logo.png, wired as the header
logo/favicon (theme.logo / theme.favicon in mkdocs.yml) and as the landing hero on
docs/index.md. To brand your site:
- Replace
docs/assets/logo.pngwith your own asset (keep the filename, or update the twomkdocs.ymlpaths and the<img>indocs/index.md). - Tune size, position and border inline on the
<img>—width="200"(scale),align="right"(position) andstyle="border-radius: 15px;"(rounded) — indocs/index.md. - Set the header colour in
docs/stylesheets/brand.css. It holds one--md-primary-fg-colorper colour scheme, so the header can match the logo; here it is the Receita Federal emblem's navy.
Repository protection & security (one-time, scripted)¶
poe init runs three admin-gated helpers. They are idempotent and non-blocking: without
gh, without auth, without a GitHub remote, or without repo-admin rights they warn and skip, so
init still completes for contributors and offline scaffolds. Re-run any of them alone later:
| Target | What it provisions |
|---|---|
poe enable_pages |
GitHub Pages source (gh-pages branch for versioned docs, else Actions) |
poe enable_repo_rules |
The pr-quality-gate branch ruleset + the merge settings the PR gate needs |
poe enable_security |
Private vulnerability reporting, Dependabot alerts, Dependabot security updates |
The pr-quality-gate ruleset¶
Applied to ~DEFAULT_BRANCH (that ref survives a branch rename), looked up by name so a
re-run updates in place instead of creating a duplicate:
| Rule | Setting | Why |
|---|---|---|
pull_request |
required_approving_review_count: 0 |
⚠️ Must be 0. GitHub forbids approving your own PR, so any value ≥ 1 locks a solo maintainer out of merging their own work. Zero still forces every change through a PR — that is the real guardrail. |
pull_request |
required_review_thread_resolution: true |
Makes review comments binding: an unresolved thread blocks the merge instead of being decorative. |
code_scanning |
CodeQL, security high_or_higher, alerts errors |
Alerts stay at errors: errors_and_warnings/all start blocking merges on stylistic queries, duplicating ruff/mypy with noise. |
copilot_code_review |
review_on_push: true |
Its own rule type — not a pull_request parameter (that returns HTTP 422 and makes the feature look UI-only). |
non_fast_forward, deletion |
on | No force-push, no branch deletion on the default branch. |
required_status_checks |
empty by default | ⚠️ Deliberate. A required check whose name never reports blocks every PR forever. Populate REQUIRED_CHECKS in bin/enable_repo_rules.sh from a real PR — gh api repos/:owner/:repo/commits/<sha>/check-runs --jq ".check_runs[].name" — then re-run. |
Deliberately not enabled: Require code quality results (subjective AI severity on the merge
path — ruff, mypy and the bin/check_*.py gates already enforce quality deterministically) and
Restrict code coverage (preview; the floor is single-sourced in .coveragerc fail_under).
Automatic vs manual — the boundary is repo config vs account plan¶
Nothing here needs a click. Every repository setting above is scripted. What is not
scriptable is your account's entitlement: the copilot_code_review rule only fires if the author
has access to Copilot code review, and code review is not part of Copilot Free. Without a
qualifying plan the rule sits correctly configured and inert — no review appears and nothing
errors. That silence is the trap, because the ruleset JSON looks perfect either way.
Every other rule (PR required, CI green, CodeQL clean) works regardless of any Copilot plan, so the ruleset is worth applying unconditionally. Copilot Pro is free for verified students, teachers and popular-OSS maintainers.
Do not diagnose this with
gh api user/copilot_billing→ 404: that endpoint is for org/enterprise seat management and 404s for a personal account even when Copilot Free is active.
Security¶
SECURITY.md at the repo root is auto-detected by GitHub (no API call) and flips Security
policy to Enabled; poe enable_security turns on the matching private-reporting intake plus
Dependabot alerts and security updates. Ordinary version bumps are separate — see
.github/dependabot.yml, which uses versioning-strategy: lockfile-only so it refreshes
poetry.lock (keeping CI honest about what consumers install) without ever rewriting your
pyproject ranges.
Automated PR flow (the quality gate)¶
Every PR is classified by bin/pr_gate.py (workflow pr-gate.yaml), which labels it, posts one
sticky comment with a per-axis status table, and hands the safe classes to GitHub's native
auto-merge. Two rules are the whole design:
Classified by PATH, never by diff size. The dangerous change is semantic, not big: a
one-character edit to a schema/contract constant is the smallest possible diff and the most
dangerous — and every test still passes, because the tests assert the contract that was written.
So size never decides eligibility; the changed paths do. (The one place size still matters — an
XL diff is vetoed — is waived for a lockfile-only diff, whose line count tracks how many
dependency hashes moved, not risk.)
| Risk class | Paths | Auto-merge? |
|---|---|---|
src |
src/ |
❌ defines what "passing" means |
tests |
tests/ |
❌ defines what "passing" means |
other |
anything unmatched | ❌ unknown = unsafe (default-deny) |
ci |
.github/, bin/, Makefile, tasks.sh, .pre-commit-config.yaml |
✅ |
deps |
pyproject.toml, poetry.lock, requirements.txt |
✅ (the test suite is the gate) |
docs |
docs/, mkdocs.yml, README.md, CHANGELOG.md, … |
✅ |
Consent is opt-OUT. The safe classes auto-merge with no label; add do-not-merge to force
a human merge. Native auto-merge bypasses nothing — GitHub holds the merge until every required
check of the ruleset is green, so the gate only decides eligibility, never whether it passed.
Edit the risk table (the RISK_PATHS constant) in bin/pr_gate.py for your project's real layout.
⚠️ After changing gate policy, backfill the open PRs¶
The gate runs on pull_request events, so a PR that was already open when you change the
policy (the classification/consent rules in pr_gate.py, or allow_auto_merge /
delete_branch_on_merge) is never re-evaluated — it keeps the labels and auto-merge state it
got under the old rules until some new event touches it. That is not a bug; the gate simply never
ran again. So after merging a policy change, run the backfill:
gh workflow run pr-gate.yaml -f backfill=true # re-evaluates every open PR
# for a Dependabot PR, `gh pr comment <n> --body "@dependabot rebase"` also works
Bot-merged PRs and the reconciler¶
A PR merged by native auto-merge (a bot action) does not close its linked issue and does
not delete its branch, even with delete_branch_on_merge on — bot-performed actions are
deliberately inert to prevent automation recursion. pr-reconcile.yaml fixes both: it closes the
Closes #N issues and deletes the head branch of merged PRs. Its scheduled daily run is the
actual fix — scheduled events are exempt from the "no new workflow runs" suppression that also
swallows the fast pull_request: [closed] path for a bot merge. Latency is up to one day; that is
the accepted cost of not needing a personal access token.
The work-ledger gate¶
bin/check_backlog_ledger.py (pre-commit + CI) fails a branch that touches src/ or CI paths but
adds no .specs/backlog/<kebab>_YYYYMMDD_HHMMSS.md ledger with a - [ ] checklist — making the
per-branch work-ledger convention structural instead of a thing you remember. Routine
docs/deps/tests-only branches need none.