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
41 changes: 41 additions & 0 deletions .github/workflows/bump-v1.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,47 @@ jobs:
print(f"changelog documents {version}, and [Unreleased] is empty")
PY

# Runs after every refusal above, so a release that is going to be turned
# away never has its tag rewritten.
- name: Annotate the release tag
env:
GH_TOKEN: ${{ github.token }}
RELEASE_SHA: ${{ github.sha }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
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
# 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
# "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.
#
# The rewrite keeps the same target commit, and the release references
# the tag by name, so the published release is unaffected. It lands
# seconds after publication, before the tag has realistically been
# fetched anywhere.
type=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}" --jq .object.type)
if [ "$type" = "tag" ]; then
echo "${RELEASE_TAG} is already annotated"
exit 0
fi
tag_sha=$(gh api -X POST "repos/${GITHUB_REPOSITORY}/git/tags" \
-f tag="${RELEASE_TAG}" -f message="${RELEASE_TAG}" \
-f object="${RELEASE_SHA}" -f type=commit --jq .sha)
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/git/refs/tags/${RELEASE_TAG}" \
-f sha="${tag_sha}" -F force=true
echo "${RELEASE_TAG} annotated as ${tag_sha} (still on ${RELEASE_SHA})"

- name: Move v1
env:
GH_TOKEN: ${{ github.token }}
Expand Down
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,21 @@ 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.
- **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.

## [Unreleased]

## [1.8.1] - 2026-08-07

Repairs 1.8.0, which shipped a lightweight tag and left every project unable to update. Consumers land on 1.8.1 rather than 1.8.0; the contents are the same bar this fix.

### Fixed

- `bump-v1.yml` annotates the release tag before moving `v1` onto the same commit. Copier reads a template's version with `git describe --tags`, which prefers an annotated tag and otherwise takes the newest — so with both lightweight it answered `v1`, copier parsed that as version `1`, and every consumer above 1.0.0 failed `copier update` with "Downgrades are not supported". This stranded projects completely: an explicit `--vcs-ref v1.8.0` reads the same commit and failed identically, so there was no working update path at all. Publishing a release for a tag that doesn't exist yet creates a lightweight one, so the annotation is applied here rather than asked of whoever cuts the release. It has been wrong since 1.6.0, the first release tagged this way.

## [1.8.0] - 2026-08-07

Swaps the type checker. Consumers pinned to `v1` keep passing without doing anything — the workflow's type-check step falls back to mypy — so the migration happens per project, on its next `copier update`.
Expand Down Expand Up @@ -168,4 +177,5 @@ The largest release so far: an optional TypeScript side, automated template upda
[1.6.0]: https://github.com/MattFisher/python-project-template/compare/v1.5.2...v1.6.0
[1.7.0]: https://github.com/MattFisher/python-project-template/compare/v1.6.0...v1.7.0
[1.8.0]: https://github.com/MattFisher/python-project-template/compare/v1.7.0...v1.8.0
[unreleased]: https://github.com/MattFisher/python-project-template/compare/v1.8.0...HEAD
[1.8.1]: https://github.com/MattFisher/python-project-template/compare/v1.8.0...v1.8.1
[unreleased]: https://github.com/MattFisher/python-project-template/compare/v1.8.1...HEAD