Skip to content

Commit 8fa76b9

Browse files
committed
✨ Template | CD: Add PyPI Trusted Publisher release workflow
Generated packages now auto-publish to PyPI on `vX.Y.Z` tag pushes via OIDC Trusted Publishing — no API tokens stored in the repo. The new `cd.yaml` workflow defaults to `contents: read`; only the `publish` job holds `id-token: write`, gated on a `pypi` GitHub environment that can optionally require manual approval. Publishing docs are split along their two audiences: - `docs/publishing.md` ("First publication to PyPI") covers the one-time Trusted Publisher registration and `pypi` environment setup on the repo — the steps a maintainer only does once per project. - `template/docs/developer.md.jinja` gets a `## Release` section owning the recurring flow: `hatch version` bump, tag, push, and the warning that the tag and `__about__.py` version must agree. An `important` admonition at the top, branched on the `docs` engine so both MyST and MkDocs render correctly, links back to the first-publication guide. - `docs/index.md` surfaces the capability: a `### Publish to PyPI` entry under `## Next steps` (noting you can defer publishing, but pushing an early `0.0.1` release is the way to claim the name), plus a bullet in the features list.
1 parent d546a2a commit 8fa76b9

File tree

6 files changed

+112
-0
lines changed

6 files changed

+112
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Template for Python packages based on [`copier`](https://copier.readthedocs.io/e
1515
* ⚙️ **GitHub Actions**:
1616
* Deploy documentation to [GitHub Pages](https://docs.github.com/en/pages/getting-started-with-github-pages/creating-a-github-pages-site) or [Read the Docs](https://about.readthedocs.com/).
1717
* Run pre-commit checks and tests on every pull request.
18+
* Publish to [PyPI](https://pypi.org/) on `vX.Y.Z` tags via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) — no API tokens.
1819

1920
## Usage
2021

docs/index.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ A `copier`-based template for Python packages.
1313
* ⚙️ **GitHub Actions**:
1414
* Deploy documentation to [GitHub Pages](https://docs.github.com/en/pages/getting-started-with-github-pages/creating-a-github-pages-site) or [Read the Docs](https://about.readthedocs.com/).
1515
* Run pre-commit checks and tests on every pull request.
16+
* Publish to [PyPI](https://pypi.org/) on `vX.Y.Z` tags via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) — no API tokens.
1617

1718
## Usage
1819

@@ -29,6 +30,13 @@ And answer the questions to generate a new Python package.
2930

3031
After copying the template, you might still have to do some additional configuration.
3132

33+
### Publish to PyPI
34+
35+
The generated package ships with a `cd.yaml` workflow that publishes to PyPI on `vX.Y.Z` tag pushes via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/).
36+
See [Publishing to PyPI](publishing.md) for the one-time setup steps.
37+
38+
You don't have to publish right away, but if you care about the project name, push an early `0.0.1` release to claim it on PyPI before someone else does.
39+
3240
### Deploy documentation
3341

3442
#### Read the Docs

docs/publishing.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# First publication to PyPI
2+
3+
The generated package ships with a `.github/workflows/cd.yaml` workflow that publishes to PyPI on `vX.Y.Z` tag pushes, via [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) — so you never need to store a PyPI API token as a repository secret.
4+
5+
Before the **first** release can succeed, you have to do the steps below once.
6+
Subsequent releases only require a tag push; see the generated package's own developer guide for that.
7+
8+
## One-time setup
9+
10+
1. **Push the generated package to GitHub.**
11+
2. **Create a PyPI project** (or a [pending publisher](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/) if the project doesn't exist yet).
12+
3. **Register the Trusted Publisher.** On PyPI, go to *Your projects → Publishing* and add a new publisher with:
13+
- **Owner**: your GitHub user or organisation
14+
- **Repository**: the generated repository name
15+
- **Workflow name**: `cd.yaml`
16+
- **Environment name**: `pypi`
17+
4. **Create the `pypi` environment** on the GitHub repository (*Settings → Environments → New environment*).
18+
The environment name must match what you registered on PyPI, and must exist before you push your first release tag — otherwise the `publish` job will fail to start.
19+
20+
You can optionally add required reviewers on the `pypi` environment to gate releases on manual approval.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ nav:
1111
- design.md
1212
- dev-standards.md
1313
- semantics.md
14+
- publishing.md
1415
- developer.md

template/.github/workflows/cd.yaml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: cd
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v[0-9]*.[0-9]*.[0-9]*'
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
build:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- name: Install Python
18+
uses: actions/setup-python@v5
19+
with:
20+
python-version: '3.13'
21+
22+
- name: Install Hatch
23+
run: pip install hatch
24+
25+
- name: Build sdist and wheel
26+
run: hatch build
27+
28+
- name: Upload distributions
29+
uses: actions/upload-artifact@v4
30+
with:
31+
name: dist
32+
path: dist/
33+
34+
publish:
35+
needs: build
36+
runs-on: ubuntu-latest
37+
environment: pypi
38+
permissions:
39+
id-token: write
40+
steps:
41+
- name: Download distributions
42+
uses: actions/download-artifact@v4
43+
with:
44+
name: dist
45+
path: dist/
46+
47+
- name: Publish to PyPI
48+
uses: pypa/gh-action-pypi-publish@release/v1

template/docs/developer.md.jinja

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,3 +355,37 @@ And the following rules for the files in the `tests` directory:
355355
| --------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
356356
| `INP001` | [implicit-namespace-package](https://docs.astral.sh/ruff/rules/implicit-namespace-package/) | When tests are not part of the package, there is no need for `__init__.py` files. |
357357
| `S101` | [assert](https://docs.astral.sh/ruff/rules/assert/) | Asserts should not be used in production environments, but are fine for tests. |
358+
359+
## Release
360+
361+
{% if docs == 'myst' -%}
362+
:::{important}
363+
Before the **first** release works, the repository has to be registered as a PyPI [Trusted Publisher](https://docs.pypi.org/trusted-publishers/) and a `pypi` GitHub environment has to exist.
364+
See the [`python-copier` first-publication guide](https://mbercx.github.io/python-copier/publishing/) for that one-time setup — you only do it once per project.
365+
:::
366+
{%- endif %}
367+
{%- if docs == 'mkdocs' %}
368+
!!! important
369+
Before the **first** release works, the repository has to be registered as a PyPI [Trusted Publisher](https://docs.pypi.org/trusted-publishers/) and a `pypi` GitHub environment has to exist.
370+
See the [`python-copier` first-publication guide](https://mbercx.github.io/python-copier/publishing/) for that one-time setup — you only do it once per project.
371+
{%- endif %}
372+
373+
Releases of `{{ package_name }}` are cut by pushing a `vX.Y.Z` tag to GitHub.
374+
The `cd` workflow under `.github/workflows/cd.yaml` then builds an sdist and wheel with Hatch and publishes them to PyPI.
375+
376+
1. Bump the version in `src/{{ package_name }}/__about__.py` to the new release version.
377+
The easiest way is:
378+
379+
hatch version <new-version>
380+
381+
Commit the bump on `main` (e.g. via a PR).
382+
383+
2. Tag the bump commit and push the tag:
384+
385+
git tag -a v<new-version> -m '🚀 Release v<new-version>'
386+
git push origin v<new-version>
387+
388+
3. The `cd` workflow picks up the tag, builds the distributions, and publishes them to PyPI.
389+
390+
The git tag and the version in `__about__.py` must agree.
391+
PyPI only sees the version baked into the built distribution, so a mismatch will silently publish under the wrong version (or be rejected as a duplicate of an existing release), and re-tagging after the fact is awkward.

0 commit comments

Comments
 (0)