From 410b970d97cf42efdd1f9322405cfe8a61c19e64 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Fri, 18 Sep 2026 12:38:22 +0100 Subject: [PATCH 1/3] Create the GitHub release automatically, not just the tag Zenodo archives on the GitHub release, not on the tag, so a tag alone leaves the DOI pointing at whatever was last released by hand. v1.5.5 was created manually for the JOSS submission; without this, the next archived version would be whenever someone remembered to click the button, while PyPI moved ahead. The tag step now creates the release too, skipping if one already exists, so reruns and the existing v1.5.5 are safe. The job already declares contents: write, which is what gh release create needs; it just needs GH_TOKEN in the environment. --- .github/publish-git-tag.sh | 9 +++++++++ .github/workflows/versioning.yaml | 4 +++- changelog.d/auto-release.changed.md | 1 + 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 changelog.d/auto-release.changed.md diff --git a/.github/publish-git-tag.sh b/.github/publish-git-tag.sh index 1e35385..cc4f0d4 100755 --- a/.github/publish-git-tag.sh +++ b/.github/publish-git-tag.sh @@ -22,3 +22,12 @@ fi echo "Tagging ${TAG}" git tag "${TAG}" git push origin "${TAG}" + +# Zenodo archives on the GitHub release, not the tag, so a tag alone leaves the +# DOI pointing at whatever was last released by hand. +if gh release view "${TAG}" >/dev/null 2>&1; then + echo "Release ${TAG} already exists; nothing to do." +else + echo "Creating release ${TAG}" + gh release create "${TAG}" --title "${TAG}" --generate-notes +fi diff --git a/.github/workflows/versioning.yaml b/.github/workflows/versioning.yaml index fc66f24..0ca75d3 100644 --- a/.github/workflows/versioning.yaml +++ b/.github/workflows/versioning.yaml @@ -79,5 +79,7 @@ jobs: skip-existing: true # After the upload, so a tag failure can never hold back a release. # skip-existing above keeps a rerun safe. - - name: Publish a git tag + - name: Publish a git tag and GitHub release run: ".github/publish-git-tag.sh" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/changelog.d/auto-release.changed.md b/changelog.d/auto-release.changed.md new file mode 100644 index 0000000..00c5f75 --- /dev/null +++ b/changelog.d/auto-release.changed.md @@ -0,0 +1 @@ +Releases are now created automatically from the tag, so Zenodo archives every version rather than only those released by hand. From 5197d64e50c2a373d5faf23e70cdb94c8ce70688 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Fri, 18 Sep 2026 12:45:03 +0100 Subject: [PATCH 2/3] Reach the release check on every run, not only on a fresh tag Per review: the release step sat after the two early exits, so it ran only when the script also pushed a new tag. Whenever the tag already existed the script exited 0 before reaching it - which meant a rerun after a failed release create went green having done nothing, the same silent failure #326 removed for tags, reintroduced for releases. The tag section is now an if/else that skips tagging rather than exiting, so the release check is always reached. Simulated against a bare remote with a stub gh: a fresh tag pushes then creates; an existing tag with no release still creates; both present exits 0 after one release view; a failing create turns the job red. Adds --verify-tag, so gh aborts if the tag is absent from the remote rather than creating one itself at the default branch head. Release notes now come from the towncrier CHANGELOG section for the version, falling back to --generate-notes when that section is absent. --- .github/publish-git-tag.sh | 39 ++++++++++++++++++++++---------------- .github/release_notes.py | 23 ++++++++++++++++++++++ 2 files changed, 46 insertions(+), 16 deletions(-) create mode 100644 .github/release_notes.py diff --git a/.github/publish-git-tag.sh b/.github/publish-git-tag.sh index cc4f0d4..5e9a7e1 100755 --- a/.github/publish-git-tag.sh +++ b/.github/publish-git-tag.sh @@ -1,8 +1,11 @@ #!/usr/bin/env bash -# Tag the commit that published this version. +# Tag the commit that published this version, and create its GitHub release. # # Run from the publish job after the version bump has landed on main. Exits -# non-zero if tagging fails, so a broken release is visible rather than silent. +# non-zero if either step fails, so a broken release is visible rather than +# silent. Both steps are independently idempotent: an existing tag does not +# stop the release from being created, so a rerun after a partial failure +# finishes the job rather than passing with nothing done. set -euo pipefail PYTHON=$(command -v python || command -v python3) @@ -10,24 +13,28 @@ VERSION=$("$PYTHON" -c "import re, pathlib; print(re.search(r'^version\s*=\s*\"( TAG="v${VERSION}" if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then - echo "Tag ${TAG} already exists locally; nothing to do." - exit 0 + echo "Tag ${TAG} already exists locally." +elif git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "Tag ${TAG} already exists on the remote." +else + echo "Tagging ${TAG}" + git tag "${TAG}" + git push origin "${TAG}" fi -if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then - echo "Tag ${TAG} already exists on the remote; nothing to do." +# Zenodo archives on the GitHub release, not on the tag, so a tag alone leaves +# the DOI pointing at whatever was last released by hand. +if gh release view "${TAG}" >/dev/null 2>&1; then + echo "Release ${TAG} already exists." exit 0 fi -echo "Tagging ${TAG}" -git tag "${TAG}" -git push origin "${TAG}" - -# Zenodo archives on the GitHub release, not the tag, so a tag alone leaves the -# DOI pointing at whatever was last released by hand. -if gh release view "${TAG}" >/dev/null 2>&1; then - echo "Release ${TAG} already exists; nothing to do." +# --verify-tag so gh aborts if the tag is missing from the remote, rather than +# creating one itself at the default branch head. +echo "Creating release ${TAG}" +NOTES=$("$PYTHON" .github/release_notes.py "${VERSION}" 2>/dev/null || true) +if [ -n "${NOTES}" ]; then + printf '%s\n' "${NOTES}" | gh release create "${TAG}" --title "${TAG}" --verify-tag --notes-file - else - echo "Creating release ${TAG}" - gh release create "${TAG}" --title "${TAG}" --generate-notes + gh release create "${TAG}" --title "${TAG}" --verify-tag --generate-notes fi diff --git a/.github/release_notes.py b/.github/release_notes.py new file mode 100644 index 0000000..cd72474 --- /dev/null +++ b/.github/release_notes.py @@ -0,0 +1,23 @@ +"""Print the CHANGELOG section for one version, for use as release notes. + +towncrier writes the curated entry; `gh release create --generate-notes` would +write a raw commit list instead. Prints nothing if the section is absent, and +the caller falls back to generated notes. +""" + +import re +import sys +from pathlib import Path + + +def section_for(changelog: str, version: str) -> str: + pattern = rf"^## \[{re.escape(version)}\][^\n]*\n(.*?)(?=^## \[|\Z)" + match = re.search(pattern, changelog, re.M | re.S) + return match.group(1).strip() if match else "" + + +if __name__ == "__main__": + path = Path("CHANGELOG.md") + if not path.exists(): + sys.exit(0) + print(section_for(path.read_text(), sys.argv[1])) From 12bb45dc0964addbdef4efc8e85a9729c4fd2a72 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Fri, 18 Sep 2026 13:00:28 +0100 Subject: [PATCH 3/3] Decide tagging on the remote, not the local tag store gh release create --verify-tag reads the tag from the remote, so a tag that exists only locally must still be pushed; the old local-first branch skipped the push and made the release step abort. --- .github/publish-git-tag.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/publish-git-tag.sh b/.github/publish-git-tag.sh index 5e9a7e1..e7300d0 100755 --- a/.github/publish-git-tag.sh +++ b/.github/publish-git-tag.sh @@ -12,13 +12,16 @@ PYTHON=$(command -v python || command -v python3) VERSION=$("$PYTHON" -c "import re, pathlib; print(re.search(r'^version\s*=\s*\"(\d+\.\d+\.\d+)\"', pathlib.Path('pyproject.toml').read_text(), re.M).group(1))") TAG="v${VERSION}" -if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then - echo "Tag ${TAG} already exists locally." -elif git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then +# The remote is the only state that matters here: gh release create +# --verify-tag reads the tag from the remote, so a tag that exists only +# locally must still be pushed. Decide on the remote, then make the local +# tag match and push it. +if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then echo "Tag ${TAG} already exists on the remote." else echo "Tagging ${TAG}" - git tag "${TAG}" + # Create it only if it is not already here; git tag fails on an existing name. + git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null || git tag "${TAG}" git push origin "${TAG}" fi