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
2 changes: 0 additions & 2 deletions .github/cliff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<!-- 0 -->Features" },
{ message = "^fix", group = "<!-- 1 -->Fixes" },
{ message = "^perf", group = "<!-- 2 -->Performance" },
Expand Down
6 changes: 2 additions & 4 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 2 additions & 4 deletions .github/workflows/apple.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 2 additions & 4 deletions .github/workflows/build_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
6 changes: 2 additions & 4 deletions .github/workflows/format.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
6 changes: 2 additions & 4 deletions .github/workflows/maven.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 2 additions & 4 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 41 additions & 16 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand All @@ -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/*) ;;
Comment thread
andiwand marked this conversation as resolved.
*) echo "::error::$REF is neither main nor a release/vX.Y.X branch"
exit 1 ;;
esac

- name: checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 }}
Expand All @@ -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/}" \
Expand Down
6 changes: 2 additions & 4 deletions .github/workflows/tidy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
6 changes: 2 additions & 4 deletions .github/workflows/wasm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 31 additions & 37 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading