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
46 changes: 40 additions & 6 deletions .github/workflows/release-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
name: Release PR

# Two triggers that want opposite behaviour when there is nothing to release.
# A push is speculative: most merges are chore, docs or build commits, and
# git-cliff bumps only for feat, fix and breaking changes, so "nothing to
# release" is the ordinary outcome and has to end quietly. A dispatch is a
# human asking for a release, where a green run that did nothing would read as
# success, so that path still fails loudly. The `stop` helper below is that one
# decision, applied at both places that can reach it.
# A push is speculative, and the version alone cannot decide whether it is
# worth acting on: every conventional type bumps at least the patch, so a
# `chore` or `docs` merge moves the version exactly as a `fix` does. What
# separates them is the group cliff.toml parsed the commit into, which is what
# the worthiness gate below reads. Ending quietly is the ordinary outcome. A
# dispatch is a human asking for a release, so it skips that gate entirely and
# fails loudly where a green run that did nothing would read as success. The
# `stop` helper is that one decision, applied at each place that reaches it.
on:
push:
branches: [main]
Expand Down Expand Up @@ -136,6 +138,38 @@ jobs:
stop "Tag $tag already exists. Nothing to release."
fi

# Reaching here means the version moved, which is a weaker signal
# than it looks: every conventional type bumps at least the patch, so
# the weekly hook autoupdate and every Dependabot bump would each
# propose their own release. A speculative run therefore needs an
# actual reason, and only a human asking gets to skip this.
#
# cliff.toml is the only parser in play. Its commit_parsers already
# decide what each commit is, and --context hands that verdict back
# per commit, so nothing here re-implements any of it -- including
# the skip rules, which drop chore(deps), chore: merge and release
# prep before they are ever seen. Groups are matched on the ordering
# index cliff.toml assigns rather than the label, which carries an
# emoji: 00 features, 01 fixes, 02 performance, 10 revert. Change
# that set to change the policy. Everything outside it still reaches
# the changelog and still bumps the version; it just does not, on its
# own, propose a release.
#
# --unreleased still hands back the previous release alongside the
# unreleased one, so the null-version entry has to be selected or
# every fix in the last release is counted again and the gate never
# closes.
if [ "$speculative" -eq 1 ]; then
worthy="$(git cliff --unreleased --context |
jq '[.[] | select(.version == null) | .commits[]
| select(.breaking or ((.group // "") | test("^<!-- (00|01|02|10) -->")))]
| length')"
if [ "$worthy" -eq 0 ]; then
stop "Nothing since the last release is worth one; not proposing $tag."
fi
echo "Release-worthy commits behind $tag: $worthy"
fi

{
echo "release=true"
echo "tag=$tag"
Expand Down
22 changes: 4 additions & 18 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,7 @@ Using the web-based interface to make changes is fine too, and will help you by

Default flow (automated):

1. **Release PR** (`.github/workflows/release-pr.yml`) opens the release PR by itself when a
releasable commit lands on `main`. git-cliff bumps only for `feat`, `fix` and breaking
changes, so `chore`, `docs` and `build` merges — the weekly hook autoupdate and Dependabot
among them — pass through without proposing a release. To pin the version instead, run the
workflow by hand or `make release-pr`: leave `version` empty to derive it via
`git cliff --bumped-version`, or pass `X.Y.Z` / `vX.Y.Z`.
1. **Release PR** (`.github/workflows/release-pr.yml`) opens the release PR by itself when a commit worth releasing lands on `main`. Every conventional type bumps at least the patch version, so the version cannot decide that on its own; the workflow gates on the group `cliff.toml` parsed each commit into — features, fixes, performance and reverts, plus anything marked breaking. A `chore`, `docs`, `build`, `ci`, `test`, `refactor` or `style` merge — the weekly hook autoupdate and Dependabot among them — rides along in the next release without proposing one. Run the workflow by hand, or `make release-pr`, to pin the version or to release a batch containing none of those types; a manual run skips the worthiness gate. Leave `version` empty to derive it via `git cliff --bumped-version`, or pass `X.Y.Z` / `vX.Y.Z`.
1. Review and merge the generated PR (`chore(release): prepare vX.Y.Z`).
1. **Release Tag** workflow (`.github/workflows/release-tag.yml`) runs on merge of a
`release/*` branch. It creates a GPG-signed annotated tag, pushes it, and dispatches
Expand All @@ -96,18 +91,9 @@ prompt. The declaration still gates a Release Publish run dispatched by hand, wh
manual fallback path. Treat the Release Tag approval as the release decision — no tag means
no publish.

Two guards follow from that. Because the tag is what marks a release finished, **Release PR**
refuses to prepare a second one while the last prepared version is still untagged — on a push
it says so and stops, and a manual run fails. That covers the approval window: a `fix` merged
while **Release Tag** waits would otherwise propose a duplicate PR for the version already on
its way out. It also latches when an approval is _rejected_, since that leaves a prepared
version that never gets a tag; clear it with the manual fallback below, which both publishes
that release and satisfies the check.

And if the version moves while a release PR is open — a `feat` landing on top of a pending
patch — the next run opens a PR for the new version and closes the superseded one. **Release
Tag** reads the version it mints from the `release/*` branch name, so leaving the stale PR
open would leave a merge path that tags the wrong version.
Two guards follow from that. Because the tag is what marks a release finished, **Release PR** refuses to prepare a second one while the last prepared version is still untagged — on a push it says so and stops, and a manual run fails. That covers the approval window: a `fix` merged while **Release Tag** waits would otherwise propose a duplicate PR for the version already on its way out. It also latches when an approval is _rejected_, since that leaves a prepared version that never gets a tag; clear it with the manual fallback below, which both publishes that release and satisfies the check.

And if the version moves while a release PR is open — a `feat` landing on top of a pending patch — the next run opens a PR for the new version and closes the superseded one. **Release Tag** reads the version it mints from the `release/*` branch name, so leaving the stale PR open would leave a merge path that tags the wrong version.

Manual fallback:

Expand Down