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
12 changes: 7 additions & 5 deletions .github/workflows/bump-v1.yml
Original file line number Diff line number Diff line change
Expand Up @@ -146,18 +146,20 @@ jobs:
run: |
set -euo pipefail
# The Move step below puts v1 on this same commit, and copier reads a
# template's version with `git describe --tags`. That prefers an
# annotated tag over a lightweight one, and falls back to the newest
# when they tie -- so with both lightweight it answers "v1", copier
# template's version through dunamai, which takes the commit's newest
# tag by date. An annotated tag's date is when it was tagged; a
# lightweight tag's is its commit's. With both lightweight they tie on
# date and dunamai breaks the tie by name, which picks "v1"; copier
# parses that as version 1, and every consumer above 1.0.0 fails
# `copier update` with "Downgrades are not supported". Not merely the
# scheduled runs: an explicit `--vcs-ref v1.8.0` reads the same commit
# and fails identically, which strands the project entirely.
#
# Publishing a release for a tag that does not exist yet -- the GitHub
# UI's default, and `gh release create` without --verify-tag -- makes
# that lightweight tag. Rather than refuse the release over it, give
# the tag an annotation here so it outranks v1 and describe answers
# that lightweight tag, and template-release-on-merge.yml creates its
# tag lightweight too, so this step runs on every release. Rather than refuse the release over it, give
# the tag an annotation here so it outranks v1 and copier reads
# "v1.8.0". Annotating v1 too would break it again: v1 is re-tagged on
# every release, so it would always be the newer of the two.
#
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/template-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,10 @@ jobs:
# library-only files present for library, absent for app
test -f /tmp/out-library/RELEASING.md
test -f /tmp/out-library/.github/workflows/publish.yml
test ! -e /tmp/out-app/RELEASING.md
# Every project releases; only PyPI libraries get the publishing notes.
test -f /tmp/out-app/RELEASING.md
grep -q 'trusted publishing' /tmp/out-library/RELEASING.md
! grep -q 'trusted publishing' /tmp/out-app/RELEASING.md || exit 1
test ! -e /tmp/out-app/.github/workflows/publish.yml
echo "Both variants rendered and validated."

Expand Down
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

Two things are worth knowing about how versions work here, because this repo ships CI rather than a package:

- **`v1` is a moving major tag.** Consumers pin `python-ci.yml@v1` and `node-ci.yml@v1`, so publishing a release is what actually delivers a change to them — `.github/workflows/bump-v1.yml` moves `v1` onto each published `v1.x` release. Changes to the reusable workflows reach every project the moment that happens, with no `copier update` needed.
- **Release tags are annotated; `v1` stays lightweight.** They end up on the same commit, and copier reads a template's version with `git describe --tags`, which prefers the annotated tag. Get this backwards and copier reads the version as `1` and refuses every consumer's update as a downgrade. `bump-v1.yml` maintains this on its own — it annotates the release tag if publishing left it lightweight, and creates `v1` lightweight — so releases can still be cut from the GitHub UI.
- **`v1` is a moving major tag.** Consumers pin `python-ci.yml@v1`, `node-ci.yml@v1`, `template-update.yml@v1`, `prepare-release.yml@v1` and `release-on-merge.yml@v1`, so a release is what actually delivers a change to them. Releases go through the **Prepare template release** workflow (see the README); merging its pull request tags the release and starts `.github/workflows/bump-v1.yml`, which moves `v1` onto it. A release published by hand from the GitHub UI starts bump-v1 too. Changes to the reusable workflows reach every project the moment that happens, with no `copier update` needed.
- **Release tags are annotated; `v1` stays lightweight.** They end up on the same commit, and copier reads a template's version through dunamai, which takes the commit's newest tag by date: an annotated tag's date is when it was tagged, a lightweight tag's is its commit's. So the annotated release tag outranks `v1`; with both lightweight they tie on date, and dunamai breaks the tie by name, which picks `v1`. Get this backwards and copier reads the version as `1` and refuses every consumer's update as a downgrade. `bump-v1.yml` maintains this on its own — it annotates the release tag if publishing left it lightweight, and creates `v1` lightweight — so releases can still be cut from the GitHub UI.
- **Changes to *scaffolded* files reach projects only through `copier update`.** `.pre-commit-config.yaml`, `biome.json`, `pyproject.toml` and friends are copied at scaffold time, so a project picks them up when it runs an update — automatically if it opted into `template-update.yml`.

Entries for 1.0.0 through 1.5.2 were backfilled from git history after the fact, so they describe what each tag contained rather than having been written alongside it.
Expand Down
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ Generated projects release through two reusable workflows here, called from thin
- [`prepare-release.yml`](.github/workflows/prepare-release.yml), run from the project's Actions tab, bumps the version, collects `changelog.d/` into `CHANGELOG.md`, and opens a "Release vX.Y.Z" pull request.
- [`release-on-merge.yml`](.github/workflows/release-on-merge.yml), on that pull request's merge, tags the merge commit, creates the GitHub release, and can start another workflow on the tag. Generated PyPI libraries pass `dispatch-workflow: publish.yml`.

Both take `version-source`: `pyproject` for generated projects, where the version is `[project] version`, or `tags`, where it comes from the latest `vX.Y.Z` tag. Publishing stays in each project's own `publish.yml`, because PyPI trusted publishing can't run from a reusable workflow. The callers grant the permissions; the reusable workflows declare none of their own.
Both take `version-source`: `pyproject` for generated projects, where the version is `[project] version`, or `tags`, where it comes from the latest `vX.Y.Z` tag. Publishing stays in each project's own `publish.yml`, because PyPI trusted publishing can't run from a reusable workflow. The callers grant the permissions. `prepare-release.yml` asks for exactly what every caller grants (contents and pull-requests write); `release-on-merge.yml` declares none and inherits the caller's grant, since it needs `actions: write` only when it starts another workflow.

## Repo settings as code

Expand Down Expand Up @@ -214,16 +214,17 @@ project's local edits, so customisations survive.

Answering no leaves the workflow out. It does **not** cut the project off from
template updates: `.copier-answers.yml` is written either way, so
`uvx copier update` still works by hand whenever you want it. Worth declining
`uvx copier update --trust` still works by hand whenever you want it. Worth declining
for a repo that should pull template changes on its own schedule rather than
weekly — one in a release freeze, or one whose local edits have diverged far
enough that every update run conflicts and the PRs become noise.

Two things worth knowing about the scope:

- **Reusable workflow changes need no update run.** Consumers pin
`python-ci.yml@v1` and `node-ci.yml@v1`, so moving the `v1` tag propagates
those immediately. The update workflow exists only for the copied files —
`python-ci.yml@v1`, `node-ci.yml@v1`, `template-update.yml@v1` and the
release workflows (`prepare-release.yml@v1`, `release-on-merge.yml@v1`), so
moving the `v1` tag propagates those immediately. The update workflow exists only for the copied files —
`.pre-commit-config.yaml`, `biome.json`, `pyproject.toml` and friends.
- **It requires `.copier-answers.yml`.** A project adapted by hand rather than
scaffolded has no baseline for copier to merge from, and the workflow fails
Expand All @@ -242,15 +243,15 @@ if you want that automatic. The same applies to release pull requests.

## Releasing this template

The template releases itself with the same reusable workflows, so each release exercises them before `v1` moves to it:

One-time setup: _Settings → Actions → General_ → **Allow GitHub Actions to create and approve pull requests**, or step 1 fails when it opens the pull request.
The template releases itself with the same reusable workflows, so each release exercises them before `v1` moves to it. One-time setup: _Settings → Actions → General_ → **Allow GitHub Actions to create and approve pull requests**, or step 1 fails when it opens the pull request.

1. _Actions_ → **Prepare template release** → _Run workflow_. It takes the next version from the latest `vX.Y.Z` tag, collects `changelog.d/` into `CHANGELOG.md`, and opens a **Release vX.Y.Z** pull request.
2. On the pull request's Checks tab, click **Approve workflows to run**, then review it. Add a summary paragraph under the new heading if the release needs one.
3. Merge it. **Template release on merge** tags the merge commit and creates the GitHub release, then starts `bump-v1.yml`, which checks the changelog, annotates the tag, and moves `v1`.

Each pull request to this repo adds a fragment under `changelog.d/` (`uvx --from scriv scriv create`), configured by `changelog.d/scriv.ini`. Publishing a release by hand from the GitHub UI still works: `bump-v1.yml` runs on the release event as before.
If a step fails, the "If a step fails" notes in [`template/RELEASING.md.jinja`](template/RELEASING.md.jinja) apply here too, minus the PyPI parts. Re-running Prepare template release replaces its own branch, and re-running Template release on merge skips what already succeeded. If bump-v1 fails after the tag exists, re-run it, or start it with `gh workflow run bump-v1.yml -R Generality-Labs/python-project-template --ref vX.Y.Z` (the `-R` matters in a checkout with an `upstream` remote, which `gh` would otherwise pick). On a dispatch, it accepts only the newest final `v1.X.Y` tag.

Each pull request to this repo adds a fragment under `changelog.d/` (`uvx --from scriv scriv create`), configured by `changelog.d/scriv.ini`. Publishing a release by hand from the GitHub UI still works: `bump-v1.yml` runs on the release event as before, and only once the release's changelog section has been collected and merged, since it checks for it.

## Versioning

Expand Down
5 changes: 5 additions & 0 deletions changelog.d/20261003_000000_release_docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Fixed

- Every generated project gets RELEASING.md, not only PyPI libraries, with the PyPI setup and publishing steps only where they apply. It covers the one-time setup (including a greyed-out Actions setting), each release, the manual path with `gh release create`, and an "If a step fails" section. That section lists every refusal, says which ones a re-run clears, and explains why a failed publish needs a new release when the fix is in the repo. A project that deleted its RELEASING.md doesn't get it back from `copier update`.
- The fragment template says Prepare release collects fragments. Every `copier update` command in the docs has `--trust`. The README lists all the workflows `v1` delivers, describes the reusable workflows' permissions correctly, and its bump-v1 recovery command passes `-R`.
- The CHANGELOG preamble and `bump-v1.yml` explain release-tag annotation correctly. Copier reads the version through dunamai, which takes the commit's newest tag by date and breaks a tie by name, so two lightweight tags give `v1`. It doesn't use `git describe`. bump-v1 annotates every release, since Release on merge creates the tag lightweight.
2 changes: 1 addition & 1 deletion copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ project_kind:

publish_to_pypi:
type: bool
help: Publish to PyPI on tag? (adds publish.yml + RELEASING.md via trusted publishing)
help: Publish to PyPI on release? (adds publish.yml, via trusted publishing, and its setup notes in RELEASING.md)
# Apps are never published; libraries usually are, but some are packaged only
# for local install (e.g. an eval), so it's asked separately.
default: "{{ project_kind == 'library' }}"
Expand Down
2 changes: 1 addition & 1 deletion template/.copier-answers.yml.jinja
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# This file is auto-generated and updated by Copier; do not edit by hand.
# Run `copier update` to pull in template changes.
# Run `uvx copier update --trust` to pull in template changes.
{{ _copier_answers|to_nice_yaml }}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ name: Template Update
# workflows need no run here — those are pinned `@v1` and propagate on their
# own when the tag moves.
#
# To update to something other than `v1`, run `uvx copier update` locally with
# `--vcs-ref`.
# To update to something other than `v1`, run `uvx copier update --trust`
# locally with `--vcs-ref`.
on:
schedule:
- cron: "0 6 * * 1" # Mondays, 06:00 UTC
Expand Down
2 changes: 0 additions & 2 deletions template/README.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@ uv run basedpyright src
Each pull request adds a changelog fragment rather than editing `CHANGELOG.md`, so concurrent PRs don't conflict. Run `uv run scriv create`, uncomment the sections that apply in the new file under `changelog.d/`, and commit it with the change, or delete it if the change needs no entry. To release, run the **Prepare release** workflow from the Actions tab. It bumps `version` in `pyproject.toml`, collects the fragments into `CHANGELOG.md`, and opens a release pull request, whose CI starts once you click **Approve workflows to run** on it; merging it tags the release. It needs _Settings → Actions → General_ → **Allow GitHub Actions to create and approve pull requests**. By hand, the same is `uv version --bump minor` and `uv run scriv collect`.

Linting (ruff, [zizmor](https://docs.zizmor.sh/), mdformat) runs via [pre-commit](https://pre-commit.com); CI runs the same stack plus basedpyright and pytest via the shared [`python-ci`](https://github.com/{{ github_owner }}/python-project-template) reusable workflow.
{% if publish_to_pypi %}

## Releasing

See [RELEASING.md](RELEASING.md).
{% endif %}
55 changes: 55 additions & 0 deletions template/RELEASING.md.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Releasing

## One-time setup

{% if publish_to_pypi %}
### PyPI trusted publishing

Publishing runs from GitHub Actions with no API tokens, via [trusted publishing](https://docs.pypi.org/trusted-publishers/).

1. On [pypi.org](https://pypi.org) → _Your account_ → _Publishing_ → **Add a pending publisher**:
- PyPI project name: `{{ project_name }}`
- Owner: `{{ github_owner }}`
- Repository name: `{{ project_name }}`
- Workflow name: `publish.yml`
- Environment name: `pypi`
2. In the GitHub repo: _Settings → Environments → New environment_ → name it `pypi`. Optionally add yourself as a required reviewer, so each publish waits for your approval.

The first release creates the PyPI project and converts the pending publisher into a normal one.

### Actions can open pull requests

{% endif %}
_Settings → Actions → General_ → enable **Allow GitHub Actions to create and approve pull requests**, so the release workflow can open its pull request. If the box is greyed out, the organization has turned it off; an organization admin can enable it under the organization's _Settings → Actions → General_.

## Each release

1. _Actions_ → **Prepare release** → _Run workflow_, choosing the bump. `auto` picks minor when a changelog fragment adds, changes, deprecates or removes something, and patch when fragments only fix; it never picks major. The workflow bumps `version` in `pyproject.toml`, collects `changelog.d/` into `CHANGELOG.md`, and opens a **Release vX.Y.Z** pull request.
2. On the pull request's Checks tab, click **Approve workflows to run**: a pull request opened by Actions starts its CI in an approval-required state. Review the pull request, and add a summary paragraph under the new heading if the release needs one.
3. Merge it. `release-on-merge.yml` tags the merge commit `vX.Y.Z` and creates the GitHub release from the changelog section{% if publish_to_pypi %}, then starts `publish.yml`, which checks the tag against the built wheel and uploads to PyPI{% endif %}.

To do the same by hand: `uv version --bump minor` (or `patch` / `major`), `uv run scriv collect`, open and merge a pull request, then on the merge commit run `git tag vX.Y.Z && git push origin vX.Y.Z`{% if publish_to_pypi %} (the tag push starts `publish.yml`){% endif %} and `gh release create vX.Y.Z` with the changelog section as its notes.

## If a step fails

- **Prepare release fails opening the pull request** with "GitHub Actions is not permitted to create or approve pull requests". Turn on the Actions setting above, then run it again.
- **Prepare release refuses.** It refuses a run from a branch other than the default, with no changelog fragments, with a fragment that has entries but no `###` heading or that quotes a scriv marker, when tag `vX.Y.Z` already exists, or while a release pull request for that version is open. Fix the fragment, close the open pull request, or, for an existing tag, choose a bigger bump. Otherwise a re-run is safe: it force-pushes its own `release/vX.Y.Z` branch.
- **The release pull request's CI never starts.** Click **Approve workflows to run** on its Checks tab. Pushing a commit to the branch yourself also starts it.
- **Something user-visible merges while the release pull request is open.** The tag goes on the merge commit, so the release ships it, but its fragment is still in `changelog.d/` and only reaches the next release's notes. Close the release pull request and run Prepare release again.
- **Release on merge fails.** Re-run the failed run from the Actions tab: it skips a tag or release that already exists. Four refusals come from the merged commit and fail again on a re-run: the branch name isn't `release/vX.Y.Z`, `pyproject.toml` at the merge commit has another version, `CHANGELOG.md` there has no `## [X.Y.Z]` section, or tag `vX.Y.Z` already points at another commit. If that tag was never released, delete it and re-run; otherwise cut a new release.
{% if publish_to_pypi %}
- **Publishing waits.** If the `pypi` environment has required reviewers, approve the deployment in the publish run.
- **Publishing fails.** `gh workflow run publish.yml --ref vX.Y.Z` re-runs the workflow and code exactly as they are at the tag, so it only helps when the cause is outside the repo: a PyPI outage, or a trusted publisher fixed on PyPI. A fix in the repo needs a new release: add a fragment describing it (Prepare release refuses to run without one), then run Prepare release. Do the same if the upload got through part-way, since PyPI never accepts a file twice.
{% endif %}

## Sanity checks before releasing

Run these before **Prepare release**:

```bash
uv run pytest
uv run basedpyright src
{% if project_kind == "library" %}
uv build && uvx twine check dist/*
{% endif %}
```
2 changes: 1 addition & 1 deletion template/changelog.d/TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<!--
A changelog fragment: one file per pull request, collected into CHANGELOG.md
at release time by `uv run scriv collect`.
by the Prepare release workflow (or `uv run scriv collect` by hand).

Uncomment the section(s) that apply and replace the example bullet. Write for
someone reading the release notes: what changed and why it matters to them.
Expand Down
Loading
Loading