From 29666e3fa572dcae408d2a51db32bacfc3661ecc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 04:27:35 +0000 Subject: [PATCH 1/2] Refuse to move v1 onto a lightweight release tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 ends up on the same commit as the release tag it is moved to, and copier reads a template's version with `git describe --tags`. That prefers an annotated tag and otherwise takes the newest, so with both tags lightweight it answered "v1" — copier parsed that as version 1 and refused every consumer's update as a downgrade from 1.x. This stranded projects rather than merely breaking the scheduled runs: an explicit `--vcs-ref v1.8.0` reads the same commit and failed identically, so no working update path existed. Annotating the release tag fixes it, because it outranks the lightweight v1 that the Move step creates. Both annotated would break again, v1 being re-tagged on every release and so always the newer. Verified against a local clone of this repo: with v1.8.0 annotated and v1 lightweight on the same commit, `git describe --tags` answers v1.8.0 and `copier update --vcs-ref v1` reports "Updating to template version 1.8.0", writing `_commit: v1.8.0`. Cutting 1.8.1 from an annotated tag is also what repairs 1.8.0, without force-pushing over a published ref. --- .github/workflows/bump-v1.yml | 26 ++++++++++++++++++++++++++ CHANGELOG.md | 12 +++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bump-v1.yml b/.github/workflows/bump-v1.yml index fe51165..b5c091b 100644 --- a/.github/workflows/bump-v1.yml +++ b/.github/workflows/bump-v1.yml @@ -57,6 +57,32 @@ jobs: ;; esac + - name: Require the release tag to be annotated + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + # Once v1 moves here it shares a commit with this release tag, 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 just the scheduled runs: an explicit `--vcs-ref v1.8.0` reads + # the same commit and fails identically, which strands every project. + # + # An annotated release tag outranks the lightweight v1 that the Move + # step creates below, so describe answers "v1.8.0" and updates work. + # Both annotated would break again -- v1 is re-tagged every release, + # so it would always be the newer of the two. + type=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}" --jq .object.type) + if [ "$type" != "tag" ]; then + echo "::error::${RELEASE_TAG} is a lightweight tag. Moving v1 onto it would break \`copier update\` for every consumer. Re-cut it as annotated, then re-publish the release: git tag -f -a ${RELEASE_TAG} -m ${RELEASE_TAG} && git push --force origin ${RELEASE_TAG}" + exit 1 + fi + echo "${RELEASE_TAG} is annotated, so it outranks the lightweight v1" + - name: Require a changelog entry for this release env: GH_TOKEN: ${{ github.token }} diff --git a/CHANGELOG.md b/CHANGELOG.md index cc848c8..c496510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 must be annotated; `v1` must stay 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` creates `v1` lightweight and refuses to move it onto a lightweight release tag, so cut releases from a tag made with `git tag -a`. - **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 + +Cut as an annotated tag, which is also what fixes 1.8.0's breakage: the annotated tag outranks the lightweight `v1` beside it, so `copier update` resolves the version again. Consumers land on 1.8.1 rather than 1.8.0; the contents are the same bar this fix. + +### Fixed + +- `bump-v1.yml` refuses to move `v1` onto a lightweight release tag. `v1` ends up on the same commit as the release tag, and 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. + ## [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`. @@ -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 From 2bb38c46ff9d4287b56cd1dc3fd9d3cee5e2ba0a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 05:20:58 +0000 Subject: [PATCH 2/2] Annotate the release tag before moving v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 ends up on the same commit as the release tag it is moved to, and copier reads a template's version with `git describe --tags`. That prefers an annotated tag and otherwise takes the newest, so with both tags lightweight it answered "v1" — copier parsed that as version 1 and refused every consumer's update as a downgrade from 1.x. This stranded projects rather than merely breaking the scheduled runs: an explicit `--vcs-ref v1.8.0` reads the same commit and failed identically, so no working update path existed. Wrong since 1.6.0, the first release whose tag was created by publishing. Publishing a release for a tag that does not yet exist is what makes it lightweight, so annotate the tag here instead of demanding the release be cut differently. Releases can still be published from the UI. The rewrite keeps the same target commit and the release references the tag by name, so the published release is unaffected, and it lands seconds after publication. Annotating v1 as well would reintroduce the bug, since v1 is re-tagged every release and would always be the newer. Placed after the existing refusals so a release that is going to be turned away never has its tag rewritten. Verified against a local clone: with the release tag annotated and v1 lightweight on one commit, `git describe --tags` answers the release tag and `copier update --vcs-ref v1` reports "Updating to template version 1.8.0" in a real consumer checkout. --- .github/workflows/bump-v1.yml | 67 +++++++++++++++++++++-------------- CHANGELOG.md | 6 ++-- 2 files changed, 44 insertions(+), 29 deletions(-) diff --git a/.github/workflows/bump-v1.yml b/.github/workflows/bump-v1.yml index b5c091b..2943e7b 100644 --- a/.github/workflows/bump-v1.yml +++ b/.github/workflows/bump-v1.yml @@ -57,32 +57,6 @@ jobs: ;; esac - - name: Require the release tag to be annotated - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - run: | - set -euo pipefail - # Once v1 moves here it shares a commit with this release tag, 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 just the scheduled runs: an explicit `--vcs-ref v1.8.0` reads - # the same commit and fails identically, which strands every project. - # - # An annotated release tag outranks the lightweight v1 that the Move - # step creates below, so describe answers "v1.8.0" and updates work. - # Both annotated would break again -- v1 is re-tagged every release, - # so it would always be the newer of the two. - type=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}" --jq .object.type) - if [ "$type" != "tag" ]; then - echo "::error::${RELEASE_TAG} is a lightweight tag. Moving v1 onto it would break \`copier update\` for every consumer. Re-cut it as annotated, then re-publish the release: git tag -f -a ${RELEASE_TAG} -m ${RELEASE_TAG} && git push --force origin ${RELEASE_TAG}" - exit 1 - fi - echo "${RELEASE_TAG} is annotated, so it outranks the lightweight v1" - - name: Require a changelog entry for this release env: GH_TOKEN: ${{ github.token }} @@ -132,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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index c496510..27a7ec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ 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 must be annotated; `v1` must stay 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` creates `v1` lightweight and refuses to move it onto a lightweight release tag, so cut releases from a tag made with `git tag -a`. +- **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. @@ -16,11 +16,11 @@ Entries for 1.0.0 through 1.5.2 were backfilled from git history after the fact, ## [1.8.1] - 2026-08-07 -Cut as an annotated tag, which is also what fixes 1.8.0's breakage: the annotated tag outranks the lightweight `v1` beside it, so `copier update` resolves the version again. Consumers land on 1.8.1 rather than 1.8.0; the contents are the same bar this fix. +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` refuses to move `v1` onto a lightweight release tag. `v1` ends up on the same commit as the release tag, and 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. +- `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