diff --git a/.github/cliff.toml b/.github/cliff.toml index 1d0ad7dc..aa2f8203 100644 --- a/.github/cliff.toml +++ b/.github/cliff.toml @@ -31,8 +31,6 @@ sort_commits = "oldest" commit_parsers = [ # the stamp commit, which would otherwise land in the next release's notes { message = "^chore\\(release\\)", skip = true }, - # the changelog heading cut, which is a version number moving and nothing else - { message = "^docs\\(changelog\\)", skip = true }, { message = "^feat", group = "Features" }, { message = "^fix", group = "Fixes" }, { message = "^perf", group = "Performance" }, diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index dc9ca411..91889a2e 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -2,10 +2,8 @@ name: android on: push: - # release branches are `release.yml`'s alone - branches-ignore: - - 'releases' - - 'release/**' + # branches only: publishing a release pushes a tag, which is not a change + branches: ['**'] release: types: [published] workflow_dispatch: diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml index c7285ebc..c1c14d66 100644 --- a/.github/workflows/apple.yml +++ b/.github/workflows/apple.yml @@ -2,10 +2,8 @@ name: apple on: push: - # release branches are `release.yml`'s alone - branches-ignore: - - 'releases' - - 'release/**' + # branches only: publishing a release pushes a tag, which is not a change + branches: ['**'] release: types: [published] workflow_dispatch: diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index 1474d872..20bc4b34 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -2,10 +2,8 @@ name: build-test on: push: - # release branches are `release.yml`'s alone - branches-ignore: - - 'releases' - - 'release/**' + # branches only: publishing a release pushes a tag, which is not a change + branches: ['**'] concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 48593feb..4556faa1 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -2,10 +2,8 @@ name: format on: push: - # release branches are `release.yml`'s alone - branches-ignore: - - 'releases' - - 'release/**' + # branches only: publishing a release pushes a tag, which is not a change + branches: ['**'] concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 0287ca77..e357f922 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -2,10 +2,8 @@ name: maven on: push: - # release branches are `release.yml`'s alone - branches-ignore: - - 'releases' - - 'release/**' + # branches only: publishing a release pushes a tag, which is not a change + branches: ['**'] release: types: [published] workflow_dispatch: diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 16d737aa..514c270a 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -2,10 +2,8 @@ name: python on: push: - # release branches are `release.yml`'s alone - branches-ignore: - - 'releases' - - 'release/**' + # branches only: publishing a release pushes a tag, which is not a change + branches: ['**'] release: types: - published diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b42a53a..0652242b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,21 +1,19 @@ name: release -# Cut the `## Unreleased` heading in CHANGELOG.md to the version being released, -# merge main into `releases`, push, publish the draft this leaves behind. +# Dispatch this against main, publish the draft it leaves behind. # # To patch an older line, branch off the *tag* — `git branch release/v6.1.X -# v6.1.0` — not off `releases`: the version is derived against the nearest -# reachable tag. +# v6.1.0` — and dispatch against that: the version is derived against the +# nearest reachable tag. +# +# A tag push cannot be the trigger: the tag has to land on the stamp commit +# this run makes. # # The release is drafted, never published. GitHub creates the tag only on # publish, so the tag can land on a commit made during the run. Publishing has # to stay a human action anyway: a release created by `GITHUB_TOKEN` raises no # `release: published`, which is what conan, maven and android hang off. on: - push: - branches: - - 'releases' - - 'release/**' workflow_dispatch: inputs: version: @@ -32,8 +30,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false +# write is the `release` job's alone — it is the one that pushes permissions: - contents: write + contents: read jobs: # Separate because the version is an input to everything below: an artifact @@ -43,6 +42,17 @@ jobs: outputs: version: ${{ steps.derive.outputs.version }} steps: + # dispatch takes any ref; `stamp` would only refuse it after the build + - name: refuse a ref that is not a release line + env: + REF: ${{ github.ref_name }} + run: | + case "$REF" in + main | release/*) ;; + *) echo "::error::$REF is neither main nor a release/vX.Y.X branch" + exit 1 ;; + esac + - name: checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: @@ -65,7 +75,7 @@ jobs: # Here rather than beside `notes`, so release copy nobody wrote costs # seconds instead of a version. Dry runs included — a dry run is the # rehearsal for the release body. - - name: check the changelog names this version + - name: check the changelog has something to say env: VERSION: ${{ steps.derive.outputs.version }} run: scripts/release.py changelog --version "$VERSION" > /dev/null @@ -86,15 +96,25 @@ jobs: release: needs: [version, apple] runs-on: ubuntu-24.04 + permissions: + contents: write # the draft env: VERSION: ${{ needs.version.outputs.version }} steps: - # `GITHUB_TOKEN`, so the stamp push below raises no `push` event and - # cannot retrigger this workflow. A PAT would loop. + # `GITHUB_TOKEN` cannot push to main: the ruleset holds it to the pull + # request rule, and GitHub Actions cannot be a bypass actor. This app can. + - name: mint a token for the app + id: app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + - name: checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 + token: ${{ steps.app.outputs.token }} # the stamp push below - name: setup python 3.14 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 @@ -126,8 +146,7 @@ jobs: # here; `stamp` commits all of it as one commit. # # The patterns match the previous release's values as well as the - # `UNRELEASED` placeholder main carries, since `releases` keeps the last - # stamp. + # `UNRELEASED` placeholder, since main keeps the last stamp. - name: point Package.swift at this release env: CHECKSUM: ${{ needs.apple.outputs.checksum }} @@ -138,13 +157,19 @@ jobs: Package.swift git diff --stat Package.swift + - name: cut the changelog + run: | + scripts/release.py cut \ + --version "$VERSION" \ + ${{ inputs.dry_run && '--dry-run' || '' }} + - name: release notes run: scripts/release.py notes --version "$VERSION" --output "${{ runner.temp }}/notes.md" - name: stamp run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config user.name "${{ steps.app.outputs.app-slug }}[bot]" + git config user.email "${{ steps.app.outputs.app-slug }}[bot]@users.noreply.github.com" scripts/release.py stamp \ --version "$VERSION" \ --branch "${GITHUB_REF#refs/heads/}" \ diff --git a/.github/workflows/tidy.yml b/.github/workflows/tidy.yml index c4108b7c..1d3ec432 100644 --- a/.github/workflows/tidy.yml +++ b/.github/workflows/tidy.yml @@ -2,10 +2,8 @@ name: tidy on: push: - # release branches are `release.yml`'s alone - branches-ignore: - - 'releases' - - 'release/**' + # branches only: publishing a release pushes a tag, which is not a change + branches: ['**'] concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index 860c63f3..4e698416 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -2,10 +2,8 @@ name: wasm on: push: - # release branches are `release.yml`'s alone - branches-ignore: - - 'releases' - - 'release/**' + # branches only: publishing a release pushes a tag, which is not a change + branches: ['**'] release: types: - published diff --git a/AGENTS.md b/AGENTS.md index 12219a03..4a49a7bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,48 +103,42 @@ cmake --build cmake-build-relwithdebinfo --target translate # CLI: file → HTM ## Releasing -Cut the changelog heading, merge main into `releases`, push, publish the draft -that appears — `.github/workflows/release.yml` and `scripts/release.py`. - -- **`main` carries no version in anything the build reads.** No file is bumped; - a build records `GIT_HEAD_SHA1` and a dirty flag and nothing else. The version - is derived from the commit subjects (`git cliff --bumped-version`), so writing - them properly is load-bearing. -- **`CHANGELOG.md` is the exception, and only as prose.** A change a consumer - would notice gets an entry under `## Unreleased` in the pull request that - makes it. Cut that heading to the version — `docs(changelog): cut vX.Y.Z` — as - the last commit on main before the merge, so both branches carry the identical - cut and the merge stays clean. The run refuses a version with no section, and - puts that section above the generated commit list in the release body. -- **Which version that is cannot be derived from main.** Since the release - train, tags sit on `chore(release)` commits that only `releases` carries, so - main's nearest reachable tag is v6.1.0 and drifts further every release — - `scripts/release.py version` only answers correctly where it runs, on - `releases` after the merge. Take the last release (`gh release list -L 1`) and - bump the minor if a `feat:` landed since, the patch otherwise. Guess wrong and - the changelog check refuses in the first job, before anything is built; re-cut - the heading, or dispatch with `--version`. -- **`releases` is the mainline train**; its first-parent history is the release - history. To patch an older line, branch off the *tag* (`git branch - release/v6.1.X v6.1.0`) — the version is derived against the nearest - *reachable* tag. That is also the trap: a `feat:` there bumps the minor to a - number the mainline may already have shipped, so `release.py version` refuses - a version that is already tagged. Pass `--version` when you mean it. Such a - branch also keeps its own changelog: it covers what that branch contains, so - it neither knows about later mainline versions nor sends its sections back to - main — a cherry-pick brings the entry with it, and the mainline entry for the - same fix can mention the backport. -- **Release branches run `release.yml` only**; every other workflow carries - `branches-ignore: ['releases', 'release/**']`. +Dispatch `release.yml` against main, publish the draft that appears — +`.github/workflows/release.yml` and `scripts/release.py`. + +- **Dispatch is the only trigger**, and the first job refuses a ref that is + neither main nor `release/**`. `dry_run` goes through the motions without + pushing — the rehearsal for the release body. +- **`main` carries no version in anything the build reads.** No file is bumped + by hand; a build records `GIT_HEAD_SHA1` and a dirty flag and nothing else. + The version is derived from the commit subjects (`git cliff + --bumped-version`), so writing them properly is load-bearing. + `scripts/release.py version` answers the same question locally. +- **`CHANGELOG.md` is written as the changes land**, under `## Unreleased`, in + the pull request that makes them; the file's header says what earns an entry. + The run heads them with the version and puts them above the generated commit + list. An empty `## Unreleased` fails the first job — say "no consumer-visible + changes" rather than nothing. +- **To patch an older line, branch off the *tag*** (`git branch release/v6.1.X + v6.1.0`) and dispatch against that — the version is derived against the + nearest *reachable* tag. That is also the trap: a `feat:` there bumps the + minor to a number the mainline may already have shipped, so + `release.py version` refuses a version that is already tagged. Pass + `--version` when you mean it. Its changelog covers what that branch contains + and is not merged back; a cherry-pick brings the entry with it. - **The release is drafted, and a human publishes it.** GitHub creates the tag only then, which is what lets it point at a commit made during the run. It also has to be a human: a release created by `GITHUB_TOKEN` raises no `release: published`, and that event starts conan, maven and android. - **`release.yml` is the only place that writes a version into the build**, and - `release.py stamp` commits it as `chore(release): vX.Y.Z`. Today that is - `Package.swift`: SwiftPM resolves it at the tag, and its binary target names - the sha256 of an archive that does not exist until the release builds it. Off - a tag the url says `UNRELEASED`. + `release.py stamp` commits it to main as `chore(release): vX.Y.Z`. Today that + is `Package.swift` and the changelog heading: SwiftPM resolves the manifest at + the tag, and its binary target names the sha256 of an archive that does not + exist until the release builds it. That push cannot use `GITHUB_TOKEN` — + GitHub Actions cannot be a bypass actor on the main ruleset — so the job mints + one from a GitHub App that is on the bypass list (`RELEASE_APP_ID`, + `RELEASE_APP_PRIVATE_KEY`). An app token does raise events, hence `[skip ci]` + in the stamp subject. - **A job that wants something attached to the release** names its artifact `release-asset-*`; `release.yml` uploads those and nothing else. - **`release-status.yml` makes a partial release loud** — it waits for the diff --git a/CHANGELOG.md b/CHANGELOG.md index 649010e1..c1325a8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,8 @@ only for what a consumer notices: API, formats, rendering, behaviour, packaging. Refactoring, tests and CI are in the generated list already. A breaking change says **Breaking** first. -Cutting the heading to the version is the last commit on `main` before the merge -into `releases`, and the run refuses a version with no section here. A -maintenance branch keeps its own history — sections written there are not merged -back. +The release run heads these entries with the version and opens a fresh +`## Unreleased`. A release with nothing under it is refused. ## Unreleased diff --git a/scripts/release.py b/scripts/release.py index 18d03b2c..0f4ed47a 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,15 +1,16 @@ #!/usr/bin/env python3 """The release procedure. -`releases` is the mainline: merge main into it and push, and the run cuts the -next version. To patch an older line, branch off the *tag* — `git branch -release/v6.1.X v6.1.0` — since the version is derived against the nearest +Dispatch `release.yml` against main and the run cuts the next version. To patch +an older line, branch off the *tag* — `git branch release/v6.1.X v6.1.0` — and +dispatch against that, since the version is derived against the nearest reachable one. Driven by `.github/workflows/release.yml`: version what the commits since the last reachable tag say comes next - changelog the CHANGELOG.md section for a version, and nothing if there is none + changelog the CHANGELOG.md section a release would be built from + cut rename `## Unreleased` to the version, and open a fresh one notes the release body: that section above the generated commit list stamp commit whatever the workflow wrote into the tree, if anything publish create or update the draft release, targeting HEAD, with assets @@ -28,6 +29,7 @@ from __future__ import annotations import argparse +import datetime import os import re import subprocess @@ -37,12 +39,15 @@ REPO_ROOT = Path(__file__).resolve().parent.parent CLIFF_CONFIG = REPO_ROOT / ".github" / "cliff.toml" CHANGELOG = REPO_ROOT / "CHANGELOG.md" +UNRELEASED = "Unreleased" # `## v6.3.0 - 2026-08-08`, or `## Unreleased`; the date is decoration VERSION_HEADING = re.compile(r"^## +(\S+)") -# skipped by `cliff.toml`, so it stays out of the next release's notes -STAMP_SUBJECT = "chore(release): {version}" +# skipped by `cliff.toml`, so it stays out of the next release's notes. +# `[skip ci]` because the app token pushing it does raise events, and the commit +# carries no code — the tree at the tag is the one main last built. +STAMP_SUBJECT = "chore(release): {version} [skip ci]" def run(command: list[str], *, capture: bool = False, dry_run: bool = False) -> str: @@ -103,17 +108,13 @@ def command_version(arguments: argparse.Namespace) -> None: print(version) -def changelog_section(version: str) -> str: - """The body under `## ` in CHANGELOG.md. - - Read before anything is built, so release copy nobody wrote costs seconds - rather than a version. Being read is what keeps the changelog from rotting. - """ - wanted = version.strip().removeprefix("v") +def section(text: str, wanted: str) -> str | None: + """The body under `## `, or None if there is no such heading.""" + wanted = wanted.strip().removeprefix("v") found = False body: list[str] = [] - for line in CHANGELOG.read_text().splitlines(): + for line in text.splitlines(): heading = VERSION_HEADING.match(line) if heading: if found: @@ -123,26 +124,59 @@ def changelog_section(version: str) -> str: if found: body.append(line) - if not found: + return "\n".join(body).strip("\n") if found else None + + +def changelog_section(version: str) -> str: + """What the release body will be built from. + + `## ` once `cut` has run, `## Unreleased` before it — the same + lines either way, so the workflow can read them before it builds anything. + """ + text = CHANGELOG.read_text() + body = section(text, version) + if body is None: + body = section(text, "Unreleased") + if body is None: raise SystemExit( - f"CHANGELOG.md has no section for {version}. Cut the `## Unreleased` " - f"heading to `## {version} - ` on main before merging into " - f"`releases` — that copy is the top of the release body." + f"CHANGELOG.md has neither a `## {version}` nor a `## Unreleased` " + f"section. One of them has to hold the release copy." ) - - text = "\n".join(body).strip("\n") - if not text: + if not body.strip(): raise SystemExit( - f"the `## {version}` section of CHANGELOG.md is empty. A release " - f"with nothing worth telling a consumer should say so, not nothing." + f"CHANGELOG.md says nothing about {version}. Write what a consumer " + f"would notice under `## Unreleased` — a release with nothing worth " + f"telling them should say so, not nothing." ) - return text + return body def command_changelog(arguments: argparse.Namespace) -> None: print(changelog_section(arguments.version)) +def command_cut(arguments: argparse.Namespace) -> None: + """Rename `## Unreleased` to the version and open an empty one above it. + + A version that already has a section keeps it, so re-running a release + neither cuts twice nor swallows what landed in between. + """ + text = CHANGELOG.read_text() + if section(text, arguments.version) is not None: + print(f"{arguments.version} is already cut") + return + + heading = f"## {UNRELEASED}\n" + if heading not in text: + raise SystemExit(f"CHANGELOG.md has no `## {UNRELEASED}` heading to cut") + + date = arguments.date or datetime.date.today().isoformat() + cut = f"## {UNRELEASED}\n\n## {arguments.version} - {date}\n" + print(f"cutting `## {UNRELEASED}` to `## {arguments.version} - {date}`") + if not arguments.dry_run: + CHANGELOG.write_text(text.replace(heading, cut, 1)) + + def command_notes(arguments: argparse.Namespace) -> None: """The hand-written section, then the commits it summarises.""" summary = changelog_section(arguments.version) @@ -153,11 +187,11 @@ def command_notes(arguments: argparse.Namespace) -> None: def command_stamp(arguments: argparse.Namespace) -> None: """Commit whatever the workflow wrote, and push it. A no-op if nothing did.""" - if arguments.branch != "releases" and not arguments.branch.startswith("release/"): + if arguments.branch != "main" and not arguments.branch.startswith("release/"): raise SystemExit( f"refusing to stamp {arguments.branch}: a release is cut from " - f"`releases`, or from a `release/v..X` branched off " - f"the tag it patches" + f"`main`, or from a `release/v..X` branched off the " + f"tag it patches" ) # Asked before staging, so `--dry-run` reaches the same verdict as a real run. @@ -229,6 +263,12 @@ def main() -> None: changelog.add_argument("--version", required=True) changelog.set_defaults(function=command_changelog) + cut = subparsers.add_parser("cut", help="head the entries with the version") + cut.add_argument("--version", required=True) + cut.add_argument("--date", help="defaults to today") + cut.add_argument("--dry-run", action="store_true") + cut.set_defaults(function=command_cut) + notes = subparsers.add_parser("notes", help="write the release body") notes.add_argument("--version", required=True) notes.add_argument("--output", type=Path, required=True)