From 8e5e2566e0c5224657d3aab5edc81c8e809efab7 Mon Sep 17 00:00:00 2001 From: Sindre Alstad Wilting Date: Sat, 15 Aug 2026 13:25:23 +0200 Subject: [PATCH 1/2] Release on push so fork contributions can publish A pull request from a fork runs with a read-only GITHUB_TOKEN and no secrets even on merge, so the release job cannot create the release and the publishing jobs have no credentials - while the run still reports success. A push to main always runs with a full-permission token, and the release action resolves the merged pull request and its label from the commit rather than from the event payload. Concurrency stops cancelling as part of this: every merge to main shares one group, so the next merge would otherwise cancel a release possibly mid-publish. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/publish.yml | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9a3b9da..ab0b14f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,8 +9,14 @@ on: release-notes: description: "Release notes" required: false - pull_request: - types: [closed] + # Releasing on push rather than on the pull_request closed event is deliberate. A pull request from a fork + # runs with a read-only GITHUB_TOKEN and no secrets even on merge, so it cannot create the release or reach + # the publishing credentials - which is how a merged, labeled fork contribution silently released nothing. + # A push to main always runs with a full-permission token, and the release action finds the merged pull + # request and its label from the commit. + push: + branches: + - main # Creating the GitHub release needs a write-capable token. permissions: @@ -22,6 +28,7 @@ jobs: outputs: version: ${{ steps.release.outputs.version }} publish: ${{ steps.release.outputs.should-publish }} + reason: ${{ steps.release.outputs.reason }} steps: - uses: actions/checkout@v4 @@ -103,3 +110,21 @@ jobs: with: version: ${{ needs.release.outputs.version }} secrets: inherit + + verify-published: + # A merge that publishes nothing is the failure mode that silently costs a release: the release job + # succeeds, every publish job is skipped for want of should-publish, and the whole run reports green. Fail + # instead, so a release that did not happen cannot be mistaken for one that did. + # + # Only for the reasons that mean something went wrong. Publishing nothing is correct and routine for the + # others - a commit pushed straight to main, a Dependabot merge, a re-run of a run that already released - + # and failing on those would make this job noise that everyone learns to ignore. + if: always() && needs.release.result == 'success' && contains(fromJSON('["no-label", "error"]'), needs.release.outputs.reason) + runs-on: ubuntu-latest + needs: [release] + + steps: + - name: Report that nothing was published + run: | + echo "::error::Nothing was published and no release was cut (reason: ${{ needs.release.outputs.reason }}). For 'no-label', add exactly one of major, minor or patch to the merged pull request and re-run this workflow - see verify-semver-label, which is meant to catch this before the merge." + exit 1 From 88924ad5ca3935af3085c650d45907e671795153 Mon Sep 17 00:00:00 2001 From: Sindre Alstad Wilting Date: Sat, 15 Aug 2026 13:25:24 +0200 Subject: [PATCH 2/2] Require a semantic version label before a pull request is merged Catching a missing label after the merge means the release is already lost and has to be cut by hand. Requiring it on the pull request moves the failure to where it costs nothing to fix, and removes the race where a label added just after the merge lands too late to count. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/verify-semver-label.yml | 51 +++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/verify-semver-label.yml diff --git a/.github/workflows/verify-semver-label.yml b/.github/workflows/verify-semver-label.yml new file mode 100644 index 0000000..cb60751 --- /dev/null +++ b/.github/workflows/verify-semver-label.yml @@ -0,0 +1,51 @@ +name: Verify Semver Label + +# A merged pull request with no major/minor/patch label produces a Publish run that reports success while +# skipping every publish step, because the release action resolves should-publish to false. That reads as a +# release having happened when nothing was published. Requiring the label here turns a silent non-release into +# a visible failure before the merge, where it costs nothing to fix. +# +# It also closes a race the publish workflow cannot: a label added moments after the merge may land too late +# for the release to pick it up. Demanding the label before the merge means there is nothing to race. +# +# Triggered on labeled/unlabeled as well as the usual events, so adding the label re-runs the check rather than +# leaving a red cross behind that only a push would clear. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +on: + pull_request: + types: [opened, reopened, synchronize, labeled, unlabeled] + # Scoped to the same branch Publish releases from. A pull request stacked onto another one's branch cannot + # cut a release, so demanding a version label of it would be asking which version a merge that publishes + # nothing should carry. + branches: + - main + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + + steps: + - name: Require exactly one semantic version label + env: + LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: | + count=$(printf '%s' "$LABELS" | jq '[.[] | select(. == "major" or . == "minor" or . == "patch")] | length') + + if [ "$count" -eq 1 ]; then + echo "Found one semantic version label." + exit 0 + fi + + if [ "$count" -eq 0 ]; then + echo "::error::This pull request has no semantic version label. Add exactly one of major, minor or patch. Without one, merging produces a Publish run that succeeds while skipping every publish step, so no release is cut and nothing is published." + else + echo "::error::This pull request carries $count semantic version labels. Exactly one of major, minor or patch is required, since the release version cannot be derived from more than one." + fi + + exit 1