diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..68875cd5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: Apache-2.0 +name: Release + +# A tag without a release page is the failure this exists to prevent: v2.5.3 was tagged, +# changelogged and announced while the release page stayed at v2.5.2, so anyone reading the +# Releases list saw the wrong version as Latest. Pushing `vX.Y.Z` now builds the page from the +# CHANGELOG section for that version; a page that already exists is left exactly as it is. +# +# `workflow_dispatch` is the rehearsal: same script, same decision, `--dry-run`, so the body +# and the asset list can be read before a tag exists. It reads the CHANGELOG of the ref it is +# dispatched on (not of the named tag), because that ref is where the release commit is being +# prepared. The tag push path checks out the tag itself. +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Tag to rehearse, e.g. v2.5.3. Dry run: nothing is tagged, created or uploaded." + required: true + type: string + +permissions: + contents: write + +# One release per tag at a time. Never cancel: a half-uploaded release is worse than a wait. +concurrency: + group: release-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false + +jobs: + release: + name: Publish the release page for the tag + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + # Same interpreter CI tests on; the build backend floor is pinned in pyproject.toml. + python-version: "3.14" + + - name: Install the build frontend + run: python -m pip install --upgrade build + + - name: Publish the release page (tag push) or rehearse it (manual dispatch) + env: + GH_TOKEN: ${{ github.token }} + # Through the environment, never interpolated into the shell: a tag name is + # attacker-controlled text on a fork, and `${{ }}` inside `run:` is substituted + # before the shell ever sees quoting. + INPUT_TAG: ${{ inputs.tag }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then + echo "rehearsing ${INPUT_TAG} against ${GITHUB_REF_NAME} ($(git rev-parse --short HEAD))" + python scripts/release_publish.py --tag "${INPUT_TAG}" --dry-run + else + python scripts/release_publish.py --tag "${GITHUB_REF_NAME}" + fi diff --git a/.gitignore b/.gitignore index 37db3cf3..579a2351 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ CLAUDE.md # local kuma tooling / wrangler cache (never track) .kuma/ .wrangler/ + +# build artifacts the release workflow produces +/build/ +/dist/ diff --git a/docs/README.md b/docs/README.md index 0ffa2127..36010f44 100644 --- a/docs/README.md +++ b/docs/README.md @@ -122,4 +122,5 @@ grouping is derived from `sprite_gen/_modules.py`, the one taxonomy table. |---|---| | [interpreter.md](interpreter.md) | Why the project venv is the only interpreter (no global `python3`, no NumPy fallback) | | [rename-gate.md](rename-gate.md) | What must move together when a vocabulary word or key is renamed | +| [release.md](release.md) | How a vX.Y.Z tag becomes a release page, what the workflow attaches, and what stays manual | | [troubleshooting.md](troubleshooting.md) | Symptoms of a pipeline that is "quietly wrong", with causes and fixes | diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 00000000..d8787147 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,85 @@ +# 릴리즈 — 태그를 push 하면 릴리즈 페이지가 생긴다 + +> Owns: How a vX.Y.Z tag becomes a release page, what the workflow attaches, and what stays manual · Index: [docs/README.md](README.md) + +릴리즈 페이지는 손으로 만들지 않는다. `vX.Y.Z` 태그가 push 되면 `.github/workflows/release.yml` +이 그 태그의 `CHANGELOG.md` 절을 본문으로 페이지를 만든다. 사람이 `gh release create` 를 기억할 +필요가 없게 하는 것이 목적이다 — v2.5.3 이 태그·CHANGELOG·README 까지 올라간 채 릴리즈 페이지만 +빠져서, 릴리즈 목록에는 v2.5.2 가 Latest 로 남아 있었던 적이 있다. + +## 한 묶음 + +1. **릴리즈 커밋** — `CHANGELOG.md` 의 `## Unreleased (vX.Y.Z)` 절을 `## vX.Y.Z - <제목>` 으로 + 바꾸고, `pyproject.toml` 의 `version` 과 `SKILL.md` 의 `version:` 을 그 버전으로 맞춘다 + (둘의 일치는 `tests/packaging/test_version_ssot.py` 가 강제한다). +2. **PR 로 `main` 머지** — `main` 은 보호 브랜치라 직접 push 가 거부된다. CI 가 초록이어야 한다. +3. **태그 push** — 코드네임 접두사 없는 `vX.Y.Z` 로. + + ```bash + git tag vX.Y.Z && git push origin vX.Y.Z + ``` + +4. **워크플로가 릴리즈를 만든다** — 태그가 가리키는 커밋에서 wheel·sdist·`SHA256SUMS` 를 빌드하고, + 그 버전의 CHANGELOG 절을 본문으로, 절이 이름 댄 GIF 를 자산으로 붙여 릴리즈를 생성한다. +5. **확인은 `gh release view`** — 태그를 push 한 것으로 끝이 아니다. 이게 성공해야 릴리즈가 끝난 것이다. + + ```bash + gh release view vX.Y.Z --repo aldegad/sprite-gen + ``` + +## 이미 있는 릴리즈는 건드리지 않는다 + +이미 페이지가 있는 태그로 워크플로가 다시 돌면 **아무것도 만들지 않고 아무것도 고치지 않고** 통과한다 +(`… already has a release page …; body and assets left untouched.`). 실패한 잡을 다시 돌려도 공개된 +페이지를 덮어쓰지 않는다는 뜻이고, 반대로 **이미 공개된 페이지의 본문·자산을 워크플로로 고칠 수는 없다** +— 고칠 일이 생기면 `gh release edit` / `gh release upload` 로 직접 한다. + +## 쇼케이스 GIF 는 레포에 커밋된 것만 붙는다 + +워크플로가 자산으로 첨부하는 GIF 는 **두 조건을 모두 만족한 것뿐**이다: + +- 그 버전의 CHANGELOG 절이 `docs/assets/<이름>.gif` 로 이름을 댄다 (본문의 맨 `<이름>.gif` 도 + `docs/assets/` 안에 그 파일이 있으면 같은 것으로 본다), **그리고** +- 그 파일이 레포에 커밋돼 있다. + +즉 **레포 밖에서 만든 GIF 는 게이트가 붙이지 못한다.** v2.5.4 처럼 쇼케이스 클립을 레포에 커밋하지 +않고 릴리즈 자산으로만 올리는 경우, 그 GIF 는 릴리즈가 생성된 뒤 손으로 올린다: + +```bash +gh release upload vX.Y.Z .gif --repo aldegad/sprite-gen +``` + +본문에서 그 클립을 보여주려면 릴리즈 다운로드 URL +(`https://github.com/aldegad/sprite-gen/releases/download/vX.Y.Z/.gif`)로 임베드하고, +공개 후 그 URL 이 200 인지 확인한다. 반대로 CHANGELOG 절이 `docs/assets/…` 경로를 이름 댔는데 그 +파일이 체크아웃에 없으면, 죽은 이미지가 달린 페이지를 내보내는 대신 **잡이 실패한다.** + +## 태그 없이 미리 돌려보기 + +`workflow_dispatch` 로 같은 스크립트를 `--dry-run` 으로 돌릴 수 있다. 태그도, 릴리즈도, 업로드도 +없이 제목·본문·첨부 목록만 잡 요약에 찍는다. 이 경로는 **dispatch 한 ref 의 CHANGELOG** 를 읽는다 +(입력한 태그의 것이 아니라) — 릴리즈 커밋을 준비하는 브랜치에서 본문을 미리 읽어보라는 뜻이다. +GitHub 은 기본 브랜치에 있는 워크플로만 dispatch 하므로, 이 파일이 `main` 에 들어간 뒤부터 쓸 수 있다. + +## 잡이 빨갛게 죽는 경우 + +전부 "조용히 이상한 페이지" 대신 실패를 고른 지점이다. + +- 태그에 해당하는 `## vX.Y.Z` 절이 `CHANGELOG.md` 에 없다 (있는 절 목록을 같이 찍는다). +- 그 절이 비어 있다. +- 절이 이름 댄 `docs/assets/…gif` 가 체크아웃에 없다. +- 빌드 디렉터리가 비어 있지 않다 — 낡은 아카이브가 이번 릴리즈 자산으로 섞여 올라가는 것을 막는다. +- `gh` 가 "릴리즈가 있다/없다" 를 답하지 못했다 (토큰 없음, 네트워크 끊김, 5xx). **모르는 답은 + '없음' 으로 읽지 않는다** — 그대로 멈춘다. + +## `release not found` 는 "레포 없음" 이기도 하다 + +`gh release view` 는 **없는 레포·권한 없는 레포**에 대고 물어도 똑같이 `release not found` 로 답한다. +"릴리즈가 아직 없다" 와 "레포 이름을 잘못 썼다" 가 같은 문장인 셈이다. 워크플로에서는 `--repo` 가 +`aldegad/sprite-gen` 으로 고정이고 잡 자신의 토큰을 쓰므로 이 모호함에 도달하지 않고, 도달하더라도 +뒤따르는 생성이 크게 실패한다. 손으로 확인할 때만 주의하면 된다 — `--repo` 오타가 "아직 릴리즈가 +없네" 로 읽힌다. `gh` 의 입자도 한계이지 스크립트가 고칠 수 있는 것이 아니다. + +## Related + +- [docs/README.md](README.md) — documentation index diff --git a/scripts/release_notes.py b/scripts/release_notes.py new file mode 100644 index 00000000..13b639e6 --- /dev/null +++ b/scripts/release_notes.py @@ -0,0 +1,216 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Render the GitHub release body for one tag out of `CHANGELOG.md`. + +`.github/workflows/release.yml` calls this when a `v*` tag is pushed, so the release +page is built from the file that already describes the release instead of from whatever +the person cutting the tag remembers to paste. A tag with no `## vX.Y.Z` section — or a +section with no content — is a failure here, not an empty release page. + +A release body is not rendered relative to the repository, so a relative +`docs/assets/.gif` link target 404s on the release page. Link targets are +rewritten to the raw URL on the release branch, and the GIFs the section names are also +reported as assets to attach, so the page keeps a frozen copy of what it shows while the +raw URL follows the branch. Two ways of naming one, because the CHANGELOG uses both: + +* `docs/assets/.gif` — an explicit path. Attached, and a path that is not in the + checkout fails the run rather than publishing a page with a dead image. +* a bare `.gif` — attached only when `docs/assets/.gif` exists here. A bare + name that resolves to nothing is prose, not a missing asset. + +Standard library only: this runs on a bare CI checkout, before the package is installed. + + .venv/bin/python scripts/release_notes.py --tag v2.5.3 --json +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_REPO = "aldegad/sprite-gen" +DEFAULT_BRANCH = "main" + +# Every GIF the section names, with or without its `docs/assets/` prefix. The optional +# prefix is greedy, so an explicit path is one match and not a bare name in disguise. +ASSET_REF = re.compile(r"(?Pdocs/assets/)?(?P[\w.-]+\.gif)") +# The same path where it is actually a link target: `](path)`, `src="path"`, `href='path'`. +LINK_TARGET = re.compile(r"""(?P
\]\(|(?:src|href)=["'])(?Pdocs/assets/[\w.-]+\.gif)""")
+# A code fence, either spelling. Only the character that opened one can close it.
+FENCE = re.compile(r"^(?P`{3,}|~{3,})")
+
+
+class ReleaseNotesError(RuntimeError):
+    """The CHANGELOG cannot answer for this tag. Never recoverable by guessing."""
+
+
+def version_of(tag: str) -> str:
+    """`v2.5.3` -> `2.5.3`. The tag policy is a bare `vX.Y.Z`, no codename prefix."""
+    if not re.fullmatch(r"v\d+\.\d+\.\d+[\w.-]*", tag):
+        raise ReleaseNotesError(f"tag {tag!r} is not a release tag (expected vX.Y.Z)")
+    return tag[1:]
+
+
+def _heading_pattern(version: str) -> re.Pattern[str]:
+    # `(?![\w.-])` so v2.5.3 matches neither the v2.5.30 heading sitting above it nor a
+    # v2.5.3-rc1 pre-release section: `version_of` accepts a `-` suffix as a tag, so the
+    # heading guard has to refuse one too, or the rc notes become the release page.
+    return re.compile(rf"^##\s+v{re.escape(version)}(?![\w.-])")
+
+
+def _heading_lines(lines: list[str]) -> list[int]:
+    """Indices of real `## ` headings: a `## …` inside a fenced block is code, not a section.
+
+    Markdown fences both ways, and a block is closed only by the character that opened it:
+    a ``` line inside a `~~~` block is the block's content. Reading one spelling as a fence
+    and the other as prose would keep the rule for quoted shell and drop it for quoted
+    markdown — which is the CHANGELOG entry most likely to contain a `## vX.Y.Z` line.
+    """
+    fence: str | None = None
+    found: list[int] = []
+    for i, line in enumerate(lines):
+        opened = FENCE.match(line)
+        if opened:
+            marker = opened.group("marker")[0]
+            if fence is None:
+                fence = marker
+            elif marker == fence:
+                fence = None
+        elif fence is None and line.startswith("## "):
+            found.append(i)
+    return found
+
+
+def extract_section(changelog: str, version: str) -> tuple[str, str]:
+    """Return (heading text, section body) for `## v …`.
+
+    The body runs to the next `## ` heading or to the end of the file, so the newest
+    section and the oldest one are read the same way.
+    """
+    pattern = _heading_pattern(version)
+    lines = changelog.splitlines()
+    headings = _heading_lines(lines)
+    start = next((i for i in headings if pattern.match(lines[i])), None)
+    if start is None:
+        known = [m.group(1) for m in (re.match(r"##\s+(v[\w.-]+)", lines[i]) for i in headings) if m]
+        raise ReleaseNotesError(
+            f"CHANGELOG.md has no `## v{version}` section. "
+            f"Newest sections: {', '.join(known[:5]) or '(none)'}"
+        )
+    end = next((j for j in headings if j > start), len(lines))
+    heading = lines[start].lstrip("#").strip()
+    body = "\n".join(lines[start + 1:end]).strip("\n")
+    if not body.strip():
+        raise ReleaseNotesError(f"the `## v{version}` section is empty; a release page needs notes")
+    return heading, body
+
+
+def referenced_assets(body: str, assets_root: Path) -> list[str]:
+    """Repo-relative GIF paths to attach, first mention first, deduplicated.
+
+    `assets_root` is the checkout the release is built from: it decides whether a bare
+    filename is one of this repository's clips or just a word ending in `.gif`.
+    """
+    seen: dict[str, None] = {}
+    missing: list[str] = []
+    for match in ASSET_REF.finditer(body):
+        path = f"docs/assets/{match.group('name')}"
+        if (assets_root / path).is_file():
+            seen.setdefault(path, None)
+        elif match.group("prefix"):
+            missing.append(path)
+    if missing:
+        raise ReleaseNotesError(
+            "the release notes name GIFs that are not in this checkout: "
+            + ", ".join(dict.fromkeys(missing))
+        )
+    return list(seen)
+
+
+def rewrite_asset_links(body: str, repo: str, branch: str) -> str:
+    """Point link targets at raw.githubusercontent.com; leave prose paths as paths."""
+    base = f"https://raw.githubusercontent.com/{repo}/{branch}/"
+    return LINK_TARGET.sub(lambda m: m.group("pre") + base + m.group("path"), body)
+
+
+def changelog_anchor(heading: str) -> str:
+    """GitHub's heading slug: lowercase, punctuation dropped, spaces to hyphens."""
+    slug = re.sub(r"[^\w\- ]", "", heading.strip().lower())
+    return slug.replace(" ", "-")
+
+
+def footer(tag: str, heading: str, repo: str, branch: str) -> str:
+    """The closing block every release page has carried: how to install, where the rest is."""
+    return (
+        "Install or update:\n"
+        "\n"
+        "```sh\n"
+        f'pip install --upgrade "sprite-gen @ git+https://github.com/{repo}.git@{tag}"\n'
+        "```\n"
+        "\n"
+        "The wheel, the source archive and `SHA256SUMS` are attached. Full notes in "
+        f"[CHANGELOG.md](https://github.com/{repo}/blob/{branch}/CHANGELOG.md#{changelog_anchor(heading)})."
+    )
+
+
+def render(
+    changelog_text: str,
+    tag: str,
+    assets_root: Path,
+    *,
+    repo: str = DEFAULT_REPO,
+    branch: str = DEFAULT_BRANCH,
+    with_footer: bool = True,
+) -> dict[str, object]:
+    """The whole release page as data: title, body, and the GIFs to attach."""
+    heading, section = extract_section(changelog_text, version_of(tag))
+    assets = referenced_assets(section, assets_root)
+    body = rewrite_asset_links(section, repo, branch)
+    if with_footer:
+        body = f"{body}\n\n{footer(tag, heading, repo, branch)}"
+    return {"tag": tag, "title": heading, "body": body, "assets": assets}
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+    parser.add_argument("--tag", required=True, help="release tag, e.g. v2.5.3")
+    parser.add_argument("--changelog", type=Path, default=REPO_ROOT / "CHANGELOG.md")
+    parser.add_argument("--assets-root", type=Path, default=None,
+                        help="where docs/assets/ is resolved (default: the changelog's directory)")
+    parser.add_argument("--repo", default=DEFAULT_REPO, help="owner/name for the raw and blob URLs")
+    parser.add_argument("--branch", default=DEFAULT_BRANCH, help="branch the raw asset URLs point at")
+    parser.add_argument("--no-footer", action="store_true", help="body is the CHANGELOG section alone")
+    parser.add_argument("--out-dir", type=Path, default=None,
+                        help="write title.txt, body.md and assets.txt here")
+    parser.add_argument("--json", action="store_true", help="print the rendered page as JSON")
+    args = parser.parse_args(argv)
+
+    try:
+        text = args.changelog.read_text(encoding="utf-8")
+    except OSError as exc:
+        print(f"release_notes: {exc}", file=sys.stderr)
+        return 2
+    try:
+        page = render(text, args.tag, args.assets_root or args.changelog.resolve().parent,
+                      repo=args.repo, branch=args.branch, with_footer=not args.no_footer)
+    except ReleaseNotesError as exc:
+        print(f"release_notes: {exc}", file=sys.stderr)
+        return 2
+
+    if args.out_dir:
+        args.out_dir.mkdir(parents=True, exist_ok=True)
+        (args.out_dir / "title.txt").write_text(str(page["title"]) + "\n", encoding="utf-8")
+        (args.out_dir / "body.md").write_text(str(page["body"]) + "\n", encoding="utf-8")
+        (args.out_dir / "assets.txt").write_text(
+            "".join(f"{path}\n" for path in page["assets"]), encoding="utf-8")
+    if args.json:
+        print(json.dumps(page, ensure_ascii=False, indent=2))
+    elif not args.out_dir:
+        print(page["body"])
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/scripts/release_publish.py b/scripts/release_publish.py
new file mode 100644
index 00000000..4646aa45
--- /dev/null
+++ b/scripts/release_publish.py
@@ -0,0 +1,172 @@
+# SPDX-License-Identifier: Apache-2.0
+"""Publish the GitHub release page for a tag, or rehearse it without touching anything.
+
+The gate this closes: a `vX.Y.Z` tag can be pushed, changelogged and announced while the
+release page is never created, leaving an older version as Latest. `.github/workflows/
+release.yml` runs this on every `v*` tag push, and the same file runs it with `--dry-run`
+on `workflow_dispatch` so the decision path can be rehearsed on a branch.
+
+Three outcomes, and no fourth:
+
+* the release already exists -> nothing is created, edited or uploaded, exit 0. Re-running
+  a tag is a no-op, so a re-run of a failed job cannot rewrite a published page.
+* it does not exist and this is a dry run -> the title, body and asset list are printed
+  and exit 0. No tag, no release, no upload.
+* it does not exist -> `gh release create` publishes it and `gh release view` confirms it.
+
+`gh` decides nothing by absence: only its own "release not found" counts as missing. Any
+other failure (no token, no network, a 5xx) stops the run instead of being read as "not
+there yet" and answered with a create.
+
+Standard library only, same as `release_notes.py`; `gh` and `python -m build` are the
+only external commands.
+
+    .venv/bin/python scripts/release_publish.py --tag v2.5.3 --dry-run
+"""
+from __future__ import annotations
+
+import argparse
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+import release_notes  # noqa: E402  (sibling script, resolved by the line above)
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+NOT_FOUND = "release not found"
+
+
+class PublishError(RuntimeError):
+    """The release cannot be decided or published. Never answered by retrying blind."""
+
+
+def _run(argv: list[str], *, cwd: Path) -> subprocess.CompletedProcess[str]:
+    return subprocess.run(argv, cwd=cwd, capture_output=True, text=True)
+
+
+def release_exists(gh: str, tag: str, *, repo: str, cwd: Path) -> bool:
+    """True / False, or raise. An unreadable answer is not a False."""
+    proc = _run([gh, "release", "view", tag, "--repo", repo, "--json", "tagName"], cwd=cwd)
+    if proc.returncode == 0:
+        return True
+    if NOT_FOUND in (proc.stderr + proc.stdout).lower():
+        return False
+    raise PublishError(
+        f"could not tell whether {tag} is released (gh exit {proc.returncode}): "
+        f"{(proc.stderr or proc.stdout).strip()}"
+    )
+
+
+def build_distribution(dist_dir: Path, *, cwd: Path) -> list[Path]:
+    """wheel + sdist + SHA256SUMS over both, built from this checkout.
+
+    The output directory has to be empty: everything in it is attached to the release, so
+    a leftover archive from an earlier version would be uploaded as part of this one.
+    """
+    if dist_dir.exists() and any(dist_dir.iterdir()):
+        raise PublishError(
+            f"{dist_dir} is not empty — build into a clean directory so stale archives "
+            f"cannot be attached to the release")
+    dist_dir.mkdir(parents=True, exist_ok=True)
+    proc = _run([sys.executable, "-m", "build", "--outdir", str(dist_dir)], cwd=cwd)
+    if proc.returncode != 0:
+        raise PublishError(f"python -m build failed:\n{(proc.stderr or proc.stdout).strip()}")
+    return checksum_distribution(dist_dir)
+
+
+def checksum_distribution(dist_dir: Path) -> list[Path]:
+    """Write SHA256SUMS next to the archives and return everything to attach."""
+    import hashlib
+
+    archives = sorted(p for p in dist_dir.iterdir() if p.suffix in {".whl", ".gz"})
+    if not archives:
+        raise PublishError(f"no wheel or sdist in {dist_dir}")
+    sums = dist_dir / "SHA256SUMS"
+    sums.write_text(
+        "".join(f"{hashlib.sha256(p.read_bytes()).hexdigest()}  {p.name}\n" for p in archives),
+        encoding="utf-8",
+    )
+    return [*archives, sums]
+
+
+def _emit(text: str, summary_path: str | None) -> None:
+    print(text)
+    if summary_path:
+        with open(summary_path, "a", encoding="utf-8") as handle:
+            handle.write(text + "\n")
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+    parser.add_argument("--tag", required=True, help="release tag, e.g. v2.5.3")
+    parser.add_argument("--dry-run", action="store_true",
+                        help="decide and print; create nothing and upload nothing")
+    parser.add_argument("--repo", default=release_notes.DEFAULT_REPO)
+    parser.add_argument("--branch", default=release_notes.DEFAULT_BRANCH,
+                        help="branch the raw asset URLs in the body point at")
+    parser.add_argument("--checkout", type=Path, default=REPO_ROOT,
+                        help="the tree the notes and the assets are read from")
+    parser.add_argument("--dist-dir", type=Path, default=None,
+                        help="where the wheel and sdist go (default: /dist)")
+    parser.add_argument("--skip-build", action="store_true",
+                        help="attach only the GIFs; for rehearsing the decision alone")
+    parser.add_argument("--gh", default="gh", help="the gh executable to call")
+    args = parser.parse_args(argv)
+
+    summary = os.environ.get("GITHUB_STEP_SUMMARY")
+    checkout = args.checkout.resolve()
+    dist_dir = (args.dist_dir or checkout / "dist").resolve()
+
+    try:
+        page = release_notes.render(
+            (checkout / "CHANGELOG.md").read_text(encoding="utf-8"),
+            args.tag,
+            checkout,
+            repo=args.repo,
+            branch=args.branch,
+        )
+        if release_exists(args.gh, args.tag, repo=args.repo, cwd=checkout):
+            _emit(f"{args.tag} already has a release page on {args.repo}; "
+                  f"body and assets left untouched.", summary)
+            return 0
+
+        assets = [str(checkout / rel) for rel in page["assets"]]
+        if args.skip_build:
+            built: list[Path] = []
+        else:
+            built = build_distribution(dist_dir, cwd=checkout)
+        assets = [str(p) for p in built] + assets
+
+        if args.dry_run:
+            listing = "\n".join(f"  {a}" for a in assets) or "  (none)"
+            _emit(
+                f"DRY RUN — {args.tag} has no release page on {args.repo}. Would create:\n"
+                f"\ntitle: {page['title']}\n\nassets:\n{listing}\n\nbody:\n{'-' * 60}\n"
+                f"{page['body']}\n{'-' * 60}\n\nNothing was created, uploaded or tagged.",
+                summary,
+            )
+            return 0
+
+        with tempfile.TemporaryDirectory() as scratch:
+            notes = Path(scratch) / "release-notes.md"
+            notes.write_text(str(page["body"]) + "\n", encoding="utf-8")
+            proc = _run([args.gh, "release", "create", args.tag, "--repo", args.repo,
+                         "--title", str(page["title"]), "--notes-file", str(notes), *assets],
+                        cwd=checkout)
+        if proc.returncode != 0:
+            raise PublishError(f"gh release create failed:\n{(proc.stderr or proc.stdout).strip()}")
+        if not release_exists(args.gh, args.tag, repo=args.repo, cwd=checkout):
+            raise PublishError(f"gh release create reported success but {args.tag} has no page")
+        _emit(f"published {args.repo} {args.tag}: {proc.stdout.strip()}", summary)
+        return 0
+    except (release_notes.ReleaseNotesError, PublishError, OSError) as exc:
+        print(f"release_publish: {exc}", file=sys.stderr)
+        return 2
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/tests/release/test_release_notes.py b/tests/release/test_release_notes.py
new file mode 100644
index 00000000..54e3876b
--- /dev/null
+++ b/tests/release/test_release_notes.py
@@ -0,0 +1,257 @@
+# SPDX-License-Identifier: Apache-2.0
+"""The release body is derived from CHANGELOG.md, or the release fails.
+
+These pin the four answers the release workflow depends on: which section belongs to the
+tag, what happens when there is none, which GIFs get attached, and which references become
+URLs. Fixtures are synthetic changelogs written in the test; the one test that reads the
+repository's own CHANGELOG is the regression that keeps the next release renderable.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "scripts"))
+
+import release_notes  # noqa: E402
+
+# The smallest thing that is a GIF; these tests only care that the path is a file.
+GIF_BYTES = bytes.fromhex("474946383961010001008000000000ffffff21f90401000000002c00000000010001000002024401003b")
+
+CHANGELOG = """\
+# Changelog
+
+Prose above the first section is not part of any release.
+
+## v1.3.0 - Newest
+
+- A line in the newest section.
+- A showcase clip: `docs/assets/wave-cube.gif`
+
+## v1.2.0 - Middle
+
+- The middle section stops before the next heading.
+
+## v1.1.0 - Oldest
+
+- The last section runs to the end of the file.
+"""
+
+
+def _repo(tmp_path: Path, changelog: str = CHANGELOG, gifs: tuple[str, ...] = ()) -> Path:
+    (tmp_path / "CHANGELOG.md").write_text(changelog, encoding="utf-8")
+    assets = tmp_path / "docs" / "assets"
+    assets.mkdir(parents=True, exist_ok=True)
+    for name in gifs:
+        (assets / name).write_bytes(GIF_BYTES)
+    return tmp_path
+
+
+def _render(tmp_path: Path, tag: str, changelog: str = CHANGELOG, gifs: tuple[str, ...] = (), **kw):
+    root = _repo(tmp_path, changelog, gifs)
+    return release_notes.render((root / "CHANGELOG.md").read_text(encoding="utf-8"), tag, root, **kw)
+
+
+# --- which section belongs to the tag -------------------------------------------------
+
+def test_the_section_for_the_tag_is_the_body(tmp_path: Path) -> None:
+    page = _render(tmp_path, "v1.2.0", with_footer=False)
+    assert page["title"] == "v1.2.0 - Middle"
+    assert page["body"] == "- The middle section stops before the next heading."
+
+
+def test_the_last_section_runs_to_the_end_of_the_file(tmp_path: Path) -> None:
+    """The oldest section has no heading after it; it must not come back empty."""
+    page = _render(tmp_path, "v1.1.0", with_footer=False)
+    assert page["body"] == "- The last section runs to the end of the file."
+
+
+def test_a_tag_with_no_section_fails(tmp_path: Path) -> None:
+    with pytest.raises(release_notes.ReleaseNotesError) as err:
+        _render(tmp_path, "v9.9.9")
+    assert "v9.9.9" in str(err.value) and "v1.3.0" in str(err.value), "say what is missing and what exists"
+
+
+def test_a_section_with_no_content_fails(tmp_path: Path) -> None:
+    empty = "# Changelog\n\n## v1.4.0 - Nothing written yet\n\n## v1.3.0 - Newest\n\n- A line.\n"
+    with pytest.raises(release_notes.ReleaseNotesError, match="empty"):
+        _render(tmp_path, "v1.4.0", empty)
+
+
+def test_a_version_is_not_matched_by_a_longer_one(tmp_path: Path) -> None:
+    """`## v1.3.01` must not answer for tag v1.3.0."""
+    longer = "# Changelog\n\n## v1.3.01 - Not this one\n\n- A line.\n"
+    with pytest.raises(release_notes.ReleaseNotesError):
+        _render(tmp_path, "v1.3.0", longer)
+
+
+def test_a_version_is_not_matched_by_a_pre_release_of_itself(tmp_path: Path) -> None:
+    """`## v1.3.0-rc1` must not answer for tag v1.3.0 — a tag may carry a `-` suffix too."""
+    rc = "# Changelog\n\n## v1.3.0-rc1 - Release candidate\n\n- Not the release's notes.\n"
+    with pytest.raises(release_notes.ReleaseNotesError) as err:
+        _render(tmp_path, "v1.3.0", rc)
+    assert "v1.3.0-rc1" in str(err.value), "name the section that was refused, suffix included"
+    page = _render(tmp_path, "v1.3.0-rc1", rc, with_footer=False)
+    assert page["title"] == "v1.3.0-rc1 - Release candidate", "the rc tag still gets its own section"
+
+
+def test_a_heading_inside_a_fenced_block_is_code_not_a_section(tmp_path: Path) -> None:
+    """A `## v1.2.0` line inside a code fence must not cut the section short or answer for a tag."""
+    fenced = (
+        "# Changelog\n\n## v1.3.0 - Newest\n\n"
+        "- What the old command printed:\n\n"
+        "```sh\n## v1.2.0 - not a heading\n```\n\n"
+        "- A line after the fence.\n\n"
+        "## v1.2.0 - Middle\n\n- The real middle section.\n"
+    )
+    newest = _render(tmp_path, "v1.3.0", fenced, with_footer=False)
+    assert "A line after the fence." in str(newest["body"])
+    middle = _render(tmp_path, "v1.2.0", fenced, with_footer=False)
+    assert middle["body"] == "- The real middle section."
+
+
+def test_a_tilde_fence_hides_a_heading_the_way_a_backtick_fence_does(tmp_path: Path) -> None:
+    """Quoted markdown uses `~~~` so its own backticks stay readable; it is still a fence."""
+    fenced = (
+        "# Changelog\n\n## v1.3.0 - Newest\n\n"
+        "- The section this release's notes are quoting:\n\n"
+        "~~~md\n## v1.2.0 - not a heading\n~~~\n\n"
+        "- A line after the fence.\n\n"
+        "## v1.2.0 - Middle\n\n- The real middle section.\n"
+    )
+    newest = _render(tmp_path, "v1.3.0", fenced, with_footer=False)
+    assert "A line after the fence." in str(newest["body"])
+    middle = _render(tmp_path, "v1.2.0", fenced, with_footer=False)
+    assert middle["body"] == "- The real middle section."
+
+
+def test_a_tag_that_is_not_a_release_tag_is_refused(tmp_path: Path) -> None:
+    for tag in ("main", "1.3.0", "release-1.3.0"):
+        with pytest.raises(release_notes.ReleaseNotesError, match="release tag"):
+            _render(tmp_path, tag)
+
+
+# --- which GIFs get attached ----------------------------------------------------------
+
+def test_a_section_with_no_gif_reference_attaches_nothing(tmp_path: Path) -> None:
+    page = _render(tmp_path, "v1.2.0")
+    assert page["assets"] == []
+
+
+def test_every_named_gif_is_attached_once_in_order(tmp_path: Path) -> None:
+    """Both spellings the CHANGELOG uses: an explicit path, then bare continuation names."""
+    section = (
+        "# Changelog\n\n## v1.3.0 - Showcase\n\n"
+        "- Clips: `docs/assets/wave-cube.gif`, `spin-cube.gif` and `hop-cube.gif`.\n"
+        "- The same clip again: `docs/assets/wave-cube.gif`.\n"
+    )
+    page = _render(tmp_path, "v1.3.0", section,
+                   gifs=("wave-cube.gif", "spin-cube.gif", "hop-cube.gif"))
+    assert page["assets"] == [
+        "docs/assets/wave-cube.gif",
+        "docs/assets/spin-cube.gif",
+        "docs/assets/hop-cube.gif",
+    ]
+
+
+def test_a_bare_name_that_is_not_in_docs_assets_is_prose(tmp_path: Path) -> None:
+    section = "# Changelog\n\n## v1.3.0 - Prose\n\n- The writer mentioned readme.gif in a sentence.\n"
+    page = _render(tmp_path, "v1.3.0", section)
+    assert page["assets"] == []
+
+
+def test_an_explicit_path_that_is_not_in_the_checkout_fails(tmp_path: Path) -> None:
+    """A dead image on a published release page is the failure; refuse before publishing."""
+    section = "# Changelog\n\n## v1.3.0 - Showcase\n\n- Clip: `docs/assets/gone.gif`.\n"
+    with pytest.raises(release_notes.ReleaseNotesError, match="gone.gif"):
+        _render(tmp_path, "v1.3.0", section)
+
+
+# --- which references become URLs -----------------------------------------------------
+
+def test_link_targets_become_raw_urls_and_prose_paths_stay_paths(tmp_path: Path) -> None:
+    section = (
+        "# Changelog\n\n## v1.3.0 - Showcase\n\n"
+        '- wave\n'
+        "- ![hop](docs/assets/hop-cube.gif)\n"
+        "- Produced as `docs/assets/wave-cube.gif`.\n"
+    )
+    page = _render(tmp_path, "v1.3.0", section, gifs=("wave-cube.gif", "hop-cube.gif"),
+                   repo="example/example", branch="main", with_footer=False)
+    raw = "https://raw.githubusercontent.com/example/example/main/docs/assets/"
+    body = str(page["body"])
+    assert f'src="{raw}wave-cube.gif"' in body
+    assert f"![hop]({raw}hop-cube.gif)" in body
+    assert "Produced as `docs/assets/wave-cube.gif`." in body, "a path in a sentence is not a link"
+
+
+# --- the closing block ----------------------------------------------------------------
+
+def test_the_footer_links_the_anchor_github_generates(tmp_path: Path) -> None:
+    page = _render(tmp_path, "v1.2.0", repo="example/example")
+    body = str(page["body"])
+    assert 'pip install --upgrade "sprite-gen @ git+https://github.com/example/example.git@v1.2.0"' in body
+    assert "CHANGELOG.md#v120---middle)" in body, "GitHub drops the dots and joins on hyphens"
+
+
+def test_no_footer_leaves_the_section_exactly_as_written(tmp_path: Path) -> None:
+    page = _render(tmp_path, "v1.2.0", with_footer=False)
+    assert "pip install" not in str(page["body"])
+
+
+def test_the_anchor_matches_githubs_slug_rules() -> None:
+    assert release_notes.changelog_anchor("v2.5.3 - Reference facing controls") == \
+        "v253---reference-facing-controls"
+    assert release_notes.changelog_anchor("v2.2.1") == "v221"
+
+
+# --- the command line the workflow calls ----------------------------------------------
+
+def _cli(*args: str) -> subprocess.CompletedProcess[str]:
+    return subprocess.run([sys.executable, str(ROOT / "scripts" / "release_notes.py"), *args],
+                          capture_output=True, text=True)
+
+
+def test_the_cli_writes_title_body_and_assets(tmp_path: Path) -> None:
+    root = _repo(tmp_path, gifs=("wave-cube.gif",))
+    out = tmp_path / "out"
+    proc = _cli("--tag", "v1.3.0", "--changelog", str(root / "CHANGELOG.md"),
+                "--out-dir", str(out), "--json")
+    assert proc.returncode == 0, proc.stderr
+    assert (out / "title.txt").read_text(encoding="utf-8").strip() == "v1.3.0 - Newest"
+    assert (out / "assets.txt").read_text(encoding="utf-8").split() == ["docs/assets/wave-cube.gif"]
+    assert json.loads(proc.stdout)["title"] == "v1.3.0 - Newest"
+
+
+def test_the_cli_exits_nonzero_when_the_section_is_missing(tmp_path: Path) -> None:
+    root = _repo(tmp_path)
+    proc = _cli("--tag", "v9.9.9", "--changelog", str(root / "CHANGELOG.md"))
+    assert proc.returncode != 0
+    assert "v9.9.9" in proc.stderr
+
+
+# --- the repository's own changelog ---------------------------------------------------
+
+def test_this_repositorys_current_version_renders(tmp_path: Path) -> None:
+    """The release commit's own notes must be renderable before the tag is pushed.
+
+    `pyproject.toml`'s version is the one about to be released; if its CHANGELOG section is
+    missing, misnamed or names a GIF that is not committed, the release workflow would fail
+    after the tag is already public. It fails here instead.
+    """
+    version = re.search(r'(?m)^version\s*=\s*"([^"]+)"\s*$',
+                        (ROOT / "pyproject.toml").read_text(encoding="utf-8"))
+    assert version, "pyproject.toml is missing [project] version"
+    page = release_notes.render((ROOT / "CHANGELOG.md").read_text(encoding="utf-8"),
+                                f"v{version.group(1)}", ROOT)
+    assert str(page["title"]).startswith(f"v{version.group(1)}")
+    assert str(page["body"]).strip()
+    for asset in page["assets"]:
+        assert (ROOT / asset).is_file(), asset
diff --git a/tests/release/test_release_publish.py b/tests/release/test_release_publish.py
new file mode 100644
index 00000000..bfda62a4
--- /dev/null
+++ b/tests/release/test_release_publish.py
@@ -0,0 +1,173 @@
+# SPDX-License-Identifier: Apache-2.0
+"""The release workflow's decision: publish once, never twice, never on a guess.
+
+`gh` is replaced by a stub that records every call and keeps its "is there a release?"
+answer in a file, so a second run sees exactly what a real re-run would see. Fixtures are
+synthetic: a throwaway changelog, a throwaway repository name, one-pixel GIFs.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+PUBLISH = ROOT / "scripts" / "release_publish.py"
+GIF_BYTES = bytes.fromhex("474946383961010001008000000000ffffff21f90401000000002c00000000010001000002024401003b")
+
+CHANGELOG = """\
+# Changelog
+
+## v1.3.0 - Showcase
+
+- One line, and a clip: `docs/assets/wave-cube.gif`.
+"""
+
+GH_STUB = '''\
+#!/usr/bin/env python3
+"""Stand-in for `gh`: answers from a state file and records what it was asked."""
+import json, os, sys
+from pathlib import Path
+
+state = Path(os.environ["GH_STUB_STATE"])
+Path(os.environ["GH_STUB_LOG"]).open("a", encoding="utf-8").write(" ".join(sys.argv[1:]) + "\\n")
+verb = sys.argv[1:3]
+
+if verb == ["release", "view"]:
+    if os.environ.get("GH_STUB_UNREACHABLE"):
+        print("HTTP 503: could not reach the API", file=sys.stderr)
+        sys.exit(1)
+    if state.exists():
+        print(json.dumps({"tagName": sys.argv[3]}))
+        sys.exit(0)
+    print("release not found", file=sys.stderr)
+    sys.exit(1)
+
+if verb == ["release", "create"]:
+    state.write_text("published", encoding="utf-8")
+    print("https://github.com/example/example/releases/tag/" + sys.argv[3])
+    sys.exit(0)
+
+print("the publisher called an unexpected gh verb: " + " ".join(sys.argv[1:]), file=sys.stderr)
+sys.exit(64)
+'''
+
+
+class Harness:
+    def __init__(self, tmp_path: Path) -> None:
+        self.checkout = tmp_path / "checkout"
+        (self.checkout / "docs" / "assets").mkdir(parents=True)
+        (self.checkout / "CHANGELOG.md").write_text(CHANGELOG, encoding="utf-8")
+        (self.checkout / "docs" / "assets" / "wave-cube.gif").write_bytes(GIF_BYTES)
+        self.state = tmp_path / "release-exists"
+        self.log = tmp_path / "gh-calls.log"
+        self.log.write_text("", encoding="utf-8")
+        self.gh = tmp_path / "gh"
+        self.gh.write_text(GH_STUB, encoding="utf-8")
+        self.gh.chmod(0o755)
+
+    def run(self, *args: str, unreachable: bool = False) -> subprocess.CompletedProcess[str]:
+        env = {**os.environ, "GH_STUB_STATE": str(self.state), "GH_STUB_LOG": str(self.log)}
+        env.pop("GITHUB_STEP_SUMMARY", None)
+        if unreachable:
+            env["GH_STUB_UNREACHABLE"] = "1"
+        return subprocess.run(
+            [sys.executable, str(PUBLISH), "--tag", "v1.3.0", "--repo", "example/example",
+             "--checkout", str(self.checkout), "--gh", str(self.gh), "--skip-build", *args],
+            capture_output=True, text=True, env=env)
+
+    @property
+    def calls(self) -> list[str]:
+        return [line for line in self.log.read_text(encoding="utf-8").splitlines() if line]
+
+    def published(self) -> bool:
+        return self.state.exists()
+
+
+@pytest.fixture
+def harness(tmp_path: Path) -> Harness:
+    return Harness(tmp_path)
+
+
+def test_a_missing_release_is_published_with_its_notes_and_gif(harness: Harness) -> None:
+    proc = harness.run()
+    assert proc.returncode == 0, proc.stderr
+    created = [c for c in harness.calls if c.startswith("release create")]
+    assert len(created) == 1, harness.calls
+    assert "v1.3.0 - Showcase" in created[0]
+    assert "wave-cube.gif" in created[0], "the showcase clip is attached, not only linked"
+    assert harness.published()
+
+
+def test_a_second_run_changes_nothing(harness: Harness) -> None:
+    """Idempotency: re-running a tag — a retried job, a re-pushed tag — must not rewrite
+    a published page. The first run creates, the second one only looks."""
+    assert harness.run().returncode == 0
+    harness.log.write_text("", encoding="utf-8")
+
+    second = harness.run()
+
+    assert second.returncode == 0, second.stderr
+    assert "already has a release page" in second.stdout
+    assert [c.split()[1] for c in harness.calls] == ["view"], harness.calls
+    assert not [c for c in harness.calls if "create" in c or "upload" in c or "edit" in c]
+
+
+def test_a_dry_run_creates_nothing_and_shows_what_it_would_create(harness: Harness) -> None:
+    proc = harness.run("--dry-run")
+    assert proc.returncode == 0, proc.stderr
+    assert "DRY RUN" in proc.stdout and "v1.3.0 - Showcase" in proc.stdout
+    assert "wave-cube.gif" in proc.stdout
+    assert [c.split()[1] for c in harness.calls] == ["view"], harness.calls
+    assert not harness.published()
+
+
+def test_a_dry_run_on_a_released_tag_reports_the_no_op(harness: Harness) -> None:
+    harness.state.write_text("published", encoding="utf-8")
+    proc = harness.run("--dry-run")
+    assert proc.returncode == 0, proc.stderr
+    assert "already has a release page" in proc.stdout
+    assert [c.split()[1] for c in harness.calls] == ["view"]
+
+
+def test_an_unreadable_answer_is_not_read_as_a_missing_release(harness: Harness) -> None:
+    """No silent fallback: a 503 must not become "no release yet" and then a create."""
+    proc = harness.run(unreachable=True)
+    assert proc.returncode == 2
+    assert "could not tell whether" in proc.stderr
+    assert not [c for c in harness.calls if "create" in c]
+    assert not harness.published()
+
+
+def test_a_missing_changelog_section_stops_before_gh_is_called(harness: Harness) -> None:
+    proc = harness.run("--tag", "v9.9.9")
+    assert proc.returncode == 2
+    assert "v9.9.9" in proc.stderr
+    assert harness.calls == [], "the notes are rendered before anything is asked of gh"
+
+
+def test_a_gif_named_in_the_notes_but_absent_stops_the_release(harness: Harness) -> None:
+    (harness.checkout / "docs" / "assets" / "wave-cube.gif").unlink()
+    proc = harness.run()
+    assert proc.returncode == 2
+    assert "wave-cube.gif" in proc.stderr
+    assert not harness.published()
+
+
+def test_a_dist_directory_with_stale_archives_is_refused(harness: Harness, tmp_path: Path) -> None:
+    """Everything in the dist dir is attached, so a leftover archive would ride along."""
+    stale = tmp_path / "dist"
+    stale.mkdir()
+    (stale / "sprite_gen-0.0.1-py3-none-any.whl").write_bytes(b"stale")
+    proc = subprocess.run(
+        [sys.executable, str(PUBLISH), "--tag", "v1.3.0", "--repo", "example/example",
+         "--checkout", str(harness.checkout), "--gh", str(harness.gh), "--dist-dir", str(stale)],
+        capture_output=True, text=True,
+        env={**os.environ, "GH_STUB_STATE": str(harness.state), "GH_STUB_LOG": str(harness.log)})
+    assert proc.returncode == 2
+    assert "not empty" in proc.stderr
+    assert not harness.published()