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
62 changes: 62 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,7 @@ CLAUDE.md
# local kuma tooling / wrangler cache (never track)
.kuma/
.wrangler/

# build artifacts the release workflow produces
/build/
/dist/
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
85 changes: 85 additions & 0 deletions docs/release.md
Original file line number Diff line number Diff line change
@@ -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 <clip>.gif --repo aldegad/sprite-gen
```

본문에서 그 클립을 보여주려면 릴리즈 다운로드 URL
(`https://github.com/aldegad/sprite-gen/releases/download/vX.Y.Z/<clip>.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
216 changes: 216 additions & 0 deletions scripts/release_notes.py
Original file line number Diff line number Diff line change
@@ -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/<name>.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/<name>.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 `<name>.gif` — attached only when `docs/assets/<name>.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"(?P<prefix>docs/assets/)?(?P<name>[\w.-]+\.gif)")
# The same path where it is actually a link target: `](path)`, `src="path"`, `href='path'`.
LINK_TARGET = re.compile(r"""(?P<pre>\]\(|(?:src|href)=["'])(?P<path>docs/assets/[\w.-]+\.gif)""")
# A code fence, either spelling. Only the character that opened one can close it.
FENCE = re.compile(r"^(?P<marker>`{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<version> …`.

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())
Loading
Loading