Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,46 @@ stays sufficient for the gate sequence): a pinned pre-commit hook locally and a
pinned `secret-scan` job in CI, kept in lockstep by the `repo-audit`
`secret-scanning` check. The allowlist lives in `.gitleaks.toml`.

## Quality gates & CI/CD

This repo is a working reference for a strict Python quality bar and CI/CD;
everything below runs identically locally and in CI. The full detail is in
[docs/dev-tooling.md](docs/dev-tooling.md).

**Quality gates** — one `check-ci` sequence, run the same way locally and in CI:

| Gate | Tool | Proves |
| --- | --- | --- |
| format | `ruff format` | consistent formatting |
| typecheck | `pyright` (strict) | types across `src`/`tests`/`tools` |
| lint | `ruff` | lint rules |
| markdownlint | `pymarkdown` | Markdown hygiene |
| codespell | `codespell` | spelling |
| import-linter | `import-linter` | import-direction boundaries |
| dependency-hygiene | `deptry` | no unused/missing/misplaced deps |
| pip-audit | `pip-audit` | no known vulnerabilities in the locked deps |
| repo-audit | `tools/repo_audit.py` | repo identity, and that the gates' own config cannot be weakened |
| build | `uv build` + wheel smoke | the built wheel installs and both entry points run |
| test | `pytest` (+ Hypothesis) | behaviour, including property-based tests |
| coverage | `coverage.py` (branch) | coverage stays at or above the honest floor |

Three more gates run outside `check-ci`: **gitleaks** (secret scanning),
**SonarCloud** (pull-request analysis), and **CodeQL** (code quality).

**CI/CD**:

- **CI** runs the same `check-ci` on every push and pull request.
- **Continuous release on merge**: every qualifying merge to `main` advances the
version (semantic-release via the Oak Semantic Release Bot) and publishes a
GitHub Release with the wheel + sdist. See [Releases](#releases).
- **Supply-chain pinning**: every Actions `uses:` is pinned to a commit SHA, with
Dependabot keeping the pins and the locked deps current.
- **Branch + tag rulesets**: PR required, required status checks, no force-push,
and `v*` tags protected.
- **Self-guarding**: `repo_audit.py` audits the gates' own configuration (the
coverage floor + branch mode, the supply-chain pins, the release workflow's
shape), so the gates cannot be quietly weakened.

## Agentic Engineering

This repo is optimised for agentic engineering. Its Practice surfaces,
Expand Down Expand Up @@ -194,7 +234,9 @@ The bump level is computed by Commitizen with this repo's policy:
stand down, and the `prevent-accidental-major` commit-msg hook rejects `type!:` /
`BREAKING CHANGE` in commits so one cannot land by accident. On the rare occasion
a major is warranted, a human engineer cuts it strategically, outside this repo's
automation. Releases publish to GitHub Releases only (not PyPI).
automation. Releases publish to GitHub Releases only, not PyPI — if a project
based on this template needs PyPI, see
[docs/publishing-to-pypi.md](docs/publishing-to-pypi.md).

The workflow needs the `RELEASE_APP_CLIENT_ID` / `RELEASE_APP_PRIVATE_KEY`
secrets and the bot added as a ruleset bypass actor — see
Expand Down
97 changes: 97 additions & 0 deletions docs/publishing-to-pypi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Publishing to PyPI

This template publishes to **GitHub Releases only** — it deliberately does not
publish to PyPI. If a project based on this template needs to publish to PyPI,
this guide shows how, using the current recommended approach.

You do not need to change how the package is built: the repo already produces a
wheel + sdist via `uv build` (Hatchling backend), and the release workflow
attaches them to every GitHub Release. Publishing to PyPI is an *additional*
workflow you opt into.

## Recommended: Trusted Publishing (OIDC, no tokens)

[PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) lets a
GitHub Actions workflow publish using short-lived OpenID Connect credentials, so
there is no long-lived API token to leak or rotate. This is the recommended
approach.

1. **Register the publisher on PyPI.** On your PyPI project page go to
*Publishing* and add a GitHub Actions trusted publisher: your org/repo, the
workflow filename (e.g. `publish-pypi.yml`), and an optional environment name.
For a brand-new project, use a
[pending publisher](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/)
so the first publish also creates the project.
2. **Add the publish workflow** below. It runs when a GitHub Release is published
(which this template does on every release), builds, and uploads via
[`pypa/gh-action-pypi-publish`](https://github.com/pypa/gh-action-pypi-publish).

```yaml
name: Publish to PyPI

on:
release:
types: [published]

permissions:
contents: read

jobs:
publish:
runs-on: ubuntu-latest
environment: pypi # optional: gate the publish behind a protected environment
permissions:
id-token: write # OIDC token for Trusted Publishing — no password needed
steps:
- uses: actions/checkout@<pin-to-a-sha> # v4
- name: Install uv
uses: astral-sh/setup-uv@<pin-to-a-sha> # v6
- name: Build the distribution
run: uv build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@<pin-to-a-sha> # release/v1
```

Because this template already creates a GitHub Release on every qualifying merge,
the `release: published` trigger fires automatically — so a PyPI publish happens
per release once the publisher is configured. To publish only some releases,
trigger on tags matching a pattern instead, or add a manual approval via the
`environment:`.

Keep every `uses:` pinned to a commit SHA (this repo's `audit_supply_chain`
enforces SHA pins, and Dependabot keeps them current).

## Test against TestPyPI first

Validate the flow against [TestPyPI](https://test.pypi.org/) before the real
index: register a trusted publisher there too, and point the publish step at it:

```yaml
- name: Publish to TestPyPI
uses: pypa/gh-action-pypi-publish@<pin-to-a-sha> # release/v1
with:
repository-url: https://test.pypi.org/legacy/
```

## Alternative: API token

If you cannot use Trusted Publishing, create a
[PyPI API token](https://pypi.org/help/#apitoken), store it as a repository
secret (e.g. `PYPI_API_TOKEN`), and pass it to the publish step:

```yaml
- uses: pypa/gh-action-pypi-publish@<pin-to-a-sha> # release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
```

Prefer Trusted Publishing where you can — an API token is a long-lived secret
that must be guarded and rotated.

## Official documentation

- [Publishing package distribution releases using GitHub Actions](https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/)
— the canonical Python Packaging guide
- [PyPI Trusted Publishers](https://docs.pypi.org/trusted-publishers/)
- [`pypa/gh-action-pypi-publish`](https://github.com/pypa/gh-action-pypi-publish)
- [uv — building and publishing a package](https://docs.astral.sh/uv/guides/package/)