diff --git a/.github/workflows/bump-v1.yml b/.github/workflows/bump-v1.yml index ebd018a..bc604f0 100644 --- a/.github/workflows/bump-v1.yml +++ b/.github/workflows/bump-v1.yml @@ -146,9 +146,10 @@ 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 @@ -156,8 +157,9 @@ jobs: # # 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. # diff --git a/.github/workflows/template-ci.yml b/.github/workflows/template-ci.yml index 67b1495..2b34552 100644 --- a/.github/workflows/template-ci.yml +++ b/.github/workflows/template-ci.yml @@ -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." diff --git a/CHANGELOG.md b/CHANGELOG.md index 73d9dee..b5aaf4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 0db05c1..15678b2 100644 --- a/README.md +++ b/README.md @@ -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 @@ -214,7 +214,7 @@ 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. @@ -222,8 +222,9 @@ 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 @@ -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 diff --git a/changelog.d/20261003_000000_release_docs.md b/changelog.d/20261003_000000_release_docs.md new file mode 100644 index 0000000..e8190ba --- /dev/null +++ b/changelog.d/20261003_000000_release_docs.md @@ -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. diff --git a/copier.yml b/copier.yml index e5df6ef..498d434 100644 --- a/copier.yml +++ b/copier.yml @@ -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' }}" diff --git a/template/.copier-answers.yml.jinja b/template/.copier-answers.yml.jinja index f0de77c..d5cb4dd 100644 --- a/template/.copier-answers.yml.jinja +++ b/template/.copier-answers.yml.jinja @@ -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 }} \ No newline at end of file diff --git a/template/.github/workflows/{% if use_template_update %}template-update.yml{% endif %}.jinja b/template/.github/workflows/{% if use_template_update %}template-update.yml{% endif %}.jinja index 6de6a4a..3ca024e 100644 --- a/template/.github/workflows/{% if use_template_update %}template-update.yml{% endif %}.jinja +++ b/template/.github/workflows/{% if use_template_update %}template-update.yml{% endif %}.jinja @@ -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 diff --git a/template/README.md.jinja b/template/README.md.jinja index 04ada3e..b8b695f 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -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 %} diff --git a/template/RELEASING.md.jinja b/template/RELEASING.md.jinja new file mode 100644 index 0000000..d5ba7c6 --- /dev/null +++ b/template/RELEASING.md.jinja @@ -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 %} +``` diff --git a/template/changelog.d/TEMPLATE.md b/template/changelog.d/TEMPLATE.md index 64e9ea6..ac30106 100644 --- a/template/changelog.d/TEMPLATE.md +++ b/template/changelog.d/TEMPLATE.md @@ -1,6 +1,6 @@