From 459c188b14fd970bf93fefa1e63b2d58ee6855e7 Mon Sep 17 00:00:00 2001 From: spbkgw-beep Date: Sun, 6 Sep 2026 22:16:33 -0700 Subject: [PATCH] feat(release): attach the built distributions to the GitHub Release on tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `v*` tag published to PyPI and left the tag bare here: no Release, and none of the artifacts the run had already built. A third job now downloads the same artifact the build job uploads and creates or updates the Release with the wheel and sdist attached. Release notes come from the changelog section for the tag rather than from generated notes, which summarise commits and so cannot describe a release that arrives as a single commit. `scripts/release_notes.py` extracts the section, rewrites its relative links absolute and pinned at the tag — a release body is not rendered relative to the repository root, so relative targets 404 — and exits non-zero when no section matches, so the workflow falls back to generated notes instead of publishing an empty body. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GeBdcy43H9CZkcjk4Jvt8n --- .github/workflows/release.yml | 34 +++++++++++++++ scripts/release_notes.py | 78 +++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 scripts/release_notes.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa84e45..84b66a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,3 +66,37 @@ jobs: uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: attestations: true + + github-release: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Download release artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: python-package-distributions + path: dist/ + + - name: Create or update the GitHub Release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + REPOSITORY: ${{ github.repository }} + run: | + if python scripts/release_notes.py --tag "${RELEASE_TAG}" --repository "${REPOSITORY}" > release-notes.md; then + NOTES=(--notes-file release-notes.md) + else + echo "::warning::no changelog section for ${RELEASE_TAG}; falling back to generated notes" + NOTES=(--generate-notes) + fi + if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then + gh release upload "${RELEASE_TAG}" dist/* --clobber + else + gh release create "${RELEASE_TAG}" dist/* --title "${RELEASE_TAG}" "${NOTES[@]}" --verify-tag + fi diff --git a/scripts/release_notes.py b/scripts/release_notes.py new file mode 100644 index 0000000..61c0817 --- /dev/null +++ b/scripts/release_notes.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Print the changelog section for one release, as GitHub Release notes. + +The release workflow feeds this to ``gh release create --notes-file``. GitHub's own +``--generate-notes`` cannot serve here: the public repository receives each version as a +single squashed commit, so the generated summary is one line no matter how much the +release contains. The changelog section already says what shipped, one line per change. + +Two transformations make a changelog section stand alone on a release page: + +1. Relative links (``docs/commands.md#init``) are rewritten against the tag. A release + body is not rendered relative to the repository root, so a relative target 404s; and + pinning to the tag rather than to ``main`` keeps old release notes pointing at the + documentation as it stood for that version. +2. The section heading itself is dropped — the release already carries the version. + +Exits 1 when the section is absent, so the caller can fall back rather than publish a +release with an empty body. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +LINK = re.compile(r"\]\(([^)]+)\)") + + +def extract(changelog: str, version: str) -> str | None: + """Return the body of ``## ``, or None when no such section exists.""" + lines = changelog.split("\n") + start = None + for i, line in enumerate(lines): + # "## 0.1.0 - 2026-09-06" and "## [0.1.0] - ..." both name this version. + if line.startswith("## ") and re.match(rf"##\s+\[?{re.escape(version)}\]?\b", line): + start = i + 1 + break + if start is None: + return None + body: list[str] = [] + for line in lines[start:]: + if line.startswith("## "): + break + body.append(line) + return "\n".join(body).strip() or None + + +def absolutise(notes: str, repository: str, tag: str) -> str: + """Rewrite repo-relative link targets to absolute URLs pinned at ``tag``.""" + base = f"https://github.com/{repository}/blob/{tag}/" + return LINK.sub( + lambda m: f"]({m.group(1)})" if m.group(1).startswith("http") else f"]({base}{m.group(1)})", + notes, + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", required=True, help="release tag, e.g. v0.1.0") + parser.add_argument("--repository", required=True, help="owner/repo, for absolute links") + parser.add_argument("--changelog", default="CHANGELOG.md", type=Path) + args = parser.parse_args() + + version = args.tag[1:] if args.tag.startswith("v") else args.tag + notes = extract(args.changelog.read_text(encoding="utf-8"), version) + if notes is None: + print(f"no changelog section for {version} in {args.changelog}", file=sys.stderr) + return 1 + print(absolutise(notes, args.repository, args.tag)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())