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. For that very first bootstrap use a plain shell (not inside poetry run, poe or an activated venv),
then let poe_exec.sh find the venv's poe for the rest:
bash bin/venv.sh # create the Poetry venv + install deps, poethepoet included
bash bin/poe_exec.sh init # .env (when the tier ships one), venv, git hooks, GitHub helpers
init composes ensure_env (seed .env; an info-line no-op here, since a library ships no
.env.example), venv (create the Poetry virtualenv, install all dependencies including
dev + docs), and precommit (install the git hooks). Poetry is auto-installed if missing.
Re-running venv from inside the project virtualenv is safe: it bootstraps from a base
interpreter, and refuses to delete the .venv it is running from.
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.
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, using the repository's issue template. State what you want to change and why, and list the files it will touch. For non-trivial work, wait for the issue to be accepted before writing code.
- Branch off the default branch as
<type>/<issue-number>-<slug>(for examplefeat/42-user-authentication), following the prefix policy (feat/…,fix/…, …). - Put
Closes #<N>in the PR body so the issue closes when the PR merges. - Fill out the PR template completely.
- 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.tomlholds a0.0.0placeholder. Do not hand-edit it. 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 published version is derived from the latest git tag matching
vX.Y.Z. On an exact tag the build is clean (0.2.0); off-tag builds get a PEP 440 dev version. The release workflows passPOETRY_DYNAMIC_VERSIONING_BYPASSto build a specific version. Build withpython -m build(a PEP 517 frontend). - A hand-bumped
versionfails thedynamic-version-stubpre-commit hook (bin/check_dynamic_version_stub.py): it contradicts the tag and the package index. The hook is a no-op for projects that do not enablepoetry-dynamic-versioning. - 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. - 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.
Serving the docs — first release¶
The first release-pypi.yaml run creates the gh-pages branch with mike deploy.
GITHUB_TOKEN cannot set the Pages source (the endpoint needs repository admin rights), so the
deploy_docs job prints the remedy in its job summary. Run poe enable_pages once as a
repository admin afterwards.
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(--md-primary-fg-color, plus theslatevariant for dark mode) to match your logo.
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.