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 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