From 1cf8ed502aa54855a0e435ff8c3f79224d2a44cc Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 16 Sep 2026 20:09:38 +0200 Subject: [PATCH] Cut the release from the changelog heading on a green main instead of a hand-pushed tag --- .github/workflows/test.yml | 18 +++++++++++ README.md | 25 +++++++------- release-tooling/publish_release.py | 52 +++++++++++++++++++----------- 3 files changed, 63 insertions(+), 32 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 83303f9..7569186 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,3 +49,21 @@ jobs: - name: Lowest supported Go if: ${{ !cancelled() && steps.changes.outputs.code == 'true' }} run: docker build --build-arg GO_VERSION=1.22.12 . + + release: + name: Gitea release from CHANGELOG.md + needs: test + if: github.event_name == 'push' + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - run: python3 release-tooling/publish_release.py + env: + GITEA_API_URL: ${{ github.api_url }} + GITEA_REPOSITORY: ${{ github.repository }} + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SHA: ${{ github.sha }} diff --git a/README.md b/README.md index e0443cc..501ba68 100644 --- a/README.md +++ b/README.md @@ -559,7 +559,8 @@ with no breaking change. From `v2` the module path carries `/vN`, so fences ship batched into as few majors as possible. [`testdata/shipped_shape.txt`](testdata/shipped_shape.txt) pins every path, each -category's format and each column's datatype and nullability; a pull request that +category's format and the categories it reads, and each column's datatype and +nullability; a pull request that changes it or `data/` adds its `CHANGELOG.md` entry, which CI checks. A removed, renamed or retyped line is a major. @@ -667,10 +668,11 @@ renamed or retyped line is a major. - **Raising the lowest supported Go is a major.** A consumer building on it breaks, which is the one test every rule above applies; Go's convention of a minor is not followed. -- **A release is a Gitea release built from the changelog.** The tag alone serves - `go get`, but prebuilt binaries need release assets, and the body being the tag's - changelog section keeps one text; a tag with no heading fails the workflow - rather than publishing an empty release. +- **The changelog heading is the one spelling of a release; CI cuts the tag.** A + tag pushed by hand is served by `go get` at once, so a tag whose commit lacks its + heading is burnt, not fixed. The heading on a gate-passed `main` commit is the + trigger instead: the tag can land only there, and the Gitea release the same job + publishes keeps one text as its body and is where prebuilt binaries will attach. - **A `--data-path` override rebinds every reference to the category it replaces.** References bind against the merged tree, so once shipped data uses `{.person}`, a consumer's `sv_SE/person.json` is what every shipped reference @@ -837,13 +839,10 @@ in its own commit: REPIN=1 docker compose run --rm --user "$(id -u):$(id -g)" test ``` -To release, head `CHANGELOG.md` with the version's section in place of `Unreleased`, -merge, then tag `main`; the release workflow publishes the Gitea release with that -section as its body: - -```sh -git tag -a v0.1.0 -m v0.1.0 && git push origin v0.1.0 -``` +To release, head `CHANGELOG.md` with the version's section in place of `Unreleased` +and merge: once `main` passes the gate, CI tags that commit `vX.Y.Z` and publishes +the Gitea release with the section as its body. A top heading of `[Unreleased]` +publishes nothing. ## Layout @@ -867,7 +866,7 @@ value.go the value proof: what a typed column or calc operand holds, chec data.go data loading: fs.FS folders/files -> namespace tree, multi-source merge cmd/fejkdata/ the fejkdata CLI data/ shipped data (JSON), embedded at build: locale folders + a misc folder -release-tooling/ the Gitea release a tag publishes +release-tooling/ the release CI publishes from the changelog heading testdata/ the pinned shipped shape (see Versioning) ``` diff --git a/release-tooling/publish_release.py b/release-tooling/publish_release.py index 9303030..40ff3e2 100644 --- a/release-tooling/publish_release.py +++ b/release-tooling/publish_release.py @@ -1,38 +1,52 @@ #!/usr/bin/env python3 -"""Create the Gitea release for a tag, its body the tag's CHANGELOG.md section. +"""Publish the Gitea release that CHANGELOG.md's top heading names, tagging SHA. -Env: GITEA_API_URL, GITEA_REPOSITORY (owner/repo), GITEA_TOKEN, TAG (vX.Y.Z). +A top heading of `[Unreleased]`, or a version already released, publishes nothing. +Env: GITEA_API_URL, GITEA_REPOSITORY (owner/repo), GITEA_TOKEN, SHA. """ import json import os import re import sys +import urllib.error import urllib.request - -def section(changelog: str, version: str) -> str | None: - m = re.search(rf"^## \[{re.escape(version)}\].*?$\n(.*?)(?=^## \[|\Z)", changelog, re.M | re.S) - return m.group(1).strip() if m else None +HEADING = re.compile(r"^## \[([^\]]+)\].*?$\n(.*?)(?=^## \[|\Z)", re.M | re.S) -def main() -> int: - tag = os.environ["TAG"] - if not re.fullmatch(r"v\d+\.\d+\.\d+", tag): - print(f"{tag}: not a release tag; a release is vX.Y.Z", file=sys.stderr) - return 1 - with open("CHANGELOG.md", encoding="utf-8") as f: - body = section(f.read(), tag[1:]) - if body is None: - print(f"CHANGELOG.md has no `## [{tag[1:]}]` heading; add the section, then tag", file=sys.stderr) - return 1 +def request(path: str, data: dict | None = None): req = urllib.request.Request( - f"{os.environ['GITEA_API_URL']}/repos/{os.environ['GITEA_REPOSITORY']}/releases", - data=json.dumps({"body": body, "name": tag, "tag_name": tag}).encode(), + f"{os.environ['GITEA_API_URL']}/repos/{os.environ['GITEA_REPOSITORY']}/{path}", + data=json.dumps(data).encode() if data else None, headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}", "Content-Type": "application/json"}, ) with urllib.request.urlopen(req) as resp: - print(json.load(resp)["html_url"]) + return json.load(resp) + + +def main() -> int: + with open("CHANGELOG.md", encoding="utf-8") as f: + top = HEADING.search(f.read()) + if top is None: + print("CHANGELOG.md has no `## [...]` heading", file=sys.stderr) + return 1 + version, body = top.group(1), top.group(2).strip() + if version == "Unreleased": + print("top heading is Unreleased; nothing to publish") + return 0 + if not re.fullmatch(r"\d+\.\d+\.\d+", version): + print(f"top heading `[{version}]` is neither Unreleased nor X.Y.Z", file=sys.stderr) + return 1 + tag = f"v{version}" + try: + print(f"{tag} already published: {request(f'releases/tags/{tag}')['html_url']}") + return 0 + except urllib.error.HTTPError as e: + if e.code != 404: + raise + release = request("releases", {"body": body, "name": tag, "tag_name": tag, "target_commitish": os.environ["SHA"]}) + print(release["html_url"]) return 0