From 4771153a3f974e6a581d1a5ded23c3758b065dab Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Tue, 15 Sep 2026 01:22:52 -0700 Subject: [PATCH] fix(release-pr): only propose worthwhile releases Every conventional type bumps at least the patch version, so the inferred version alone does not tell a fix apart from the weekly hook autoupdate. As first written the trigger would have opened a release PR on nearly every merge to main, both bot streams included: `chore: autoupdate pre-commit hooks` and `build(deps): bump ...` match no skip rule in cliff.toml, and both bump. A speculative run now also needs at least one commit that cliff.toml grouped as a feature, fix, performance change or revert, or that it marked breaking. That verdict comes from `git cliff --context`, so the skip rules and the breaking-change detection are not reimplemented here. Selecting the null-version entry matters: --unreleased returns the previous release too, and counting it would leave the gate permanently open. A manual dispatch skips the gate. A human asking for a release has already supplied the reason. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release-pr.yml | 46 +++++++++++++++++++++++++++----- CONTRIBUTING.md | 22 +++------------ 2 files changed, 44 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index ba0ae3d..bb3785f 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -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] @@ -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("^")))] + | 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" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 30727bb..1c4c5d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 @@ -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: