From b6b8190fc3ba92e32837c1413946082f853edc3a Mon Sep 17 00:00:00 2001 From: Scott Murphy Date: Mon, 24 Aug 2026 15:48:35 -0700 Subject: [PATCH 1/3] Give each pull request its own release drafter run Every pull request against a release branch computed the same concurrency group - the group is keyed on the branch it targets - so two of them pushed within seconds of each other shared one slot and cancel-in-progress had the later kill the earlier. The evicted pull request shows a cancelled check with nothing wrong with it, and never gets the labels the run exists to apply. Raising several at once, which is what rebasing a stack or merging a branch into each of them does, made it routine: 16183 was cancelled 12 seconds in by an unrelated PR. Queueing rather than cancelling would not have fixed it. GitHub holds one pending run per group and cancels the one that was pending before it, so a sweep of ten pull requests would still have shown eight cancellations, of queued runs. The group is now the pull request, so they no longer meet. Pushing twice to one pull request still supersedes its own earlier run, and pushes to a release branch still share a group by branch. They shared a slot for a reason - two runs creating the same draft at once is how duplicate drafts appear - so a pull request run no longer writes one. Release notes are built from merged pull requests: an open one contributes nothing, and the releaser was rewriting the draft it had already written. Confirmed against the live v8.0.0 draft, which lists merged pull requests only. Labels still apply on every pull request event; the draft is written by the pushes that can change it. --- .github/workflows/release-notes.yml | 33 +++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index 972437a3738..490328a9d44 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -39,9 +39,11 @@ on: branches: - '[0-9]+.[0-9]+.x' # Runs on PRs whose BASE branch is a release branch so the autolabeler can - # apply labels (bug/feature/docs/...) and the draft picks up new PRs as soon - # as they are opened. Feature-to-feature PRs (e.g. fix/foo -> feat/bar) - # are intentionally excluded - they cannot affect any release. + # apply labels (bug/feature/docs/...). The draft itself is not written here: + # release notes are built from MERGED pull requests, so a run for an open one + # can only rewrite the draft it already wrote. Feature-to-feature PRs (e.g. + # fix/foo -> feat/bar) are intentionally excluded - they cannot affect any + # release. pull_request: types: [opened, reopened, synchronize, labeled] branches: @@ -64,11 +66,21 @@ on: # uploads assets to the *current published* tag (e.g. v7.0.11). Splitting the # concurrency groups is therefore safe. # -# `cancel-in-progress: true`: if multiple pushes land on the same branch in -# quick succession, only the latest matters - the latest run sees every PR -# the older one would have seen, so cancelling pending runs is correct. +# Keyed on the pull request, not on the branch it targets. Every open PR against +# a release branch used to compute the same group name, so two PRs pushed within +# seconds of each other landed in one slot and `cancel-in-progress` had the later +# one kill the earlier: a red "cancelled" check on a PR that nothing was wrong +# with, and no labels applied to it. Raising several PRs at once - rebasing a +# stack, or merging a branch into each of them - made that routine. +# +# Queueing instead of cancelling would not have fixed it: GitHub keeps one +# pending run per group and cancels the previously pending one, so a sweep of +# ten PRs would still have shown eight cancellations, just of queued runs. +# +# `cancel-in-progress: true` remains right within a single subject: pushing twice +# to the same PR, or twice to a release branch, means only the later run matters. concurrency: - group: release-drafter-${{ github.event.pull_request.base.ref || github.ref_name }} + group: release-drafter-${{ github.event.pull_request.number || github.ref_name }} cancel-in-progress: true jobs: @@ -154,6 +166,13 @@ jobs: # silently produced no draft (e.g. rate-limit exhaustion). continue-on-error: true with: + # A pull request run labels; it does not draft. Release notes are built + # from merged pull requests, so an open one contributes nothing and the + # releaser would rewrite the identical draft - which is also what forced + # every PR to share a concurrency slot, since two runs creating the same + # draft at once is how duplicate drafts appear. Pushes to the release + # branch, which is when a merge actually changes the notes, still draft. + disable-releaser: ${{ github.event_name == 'pull_request' }} # Explicit `commitish` is critical on `pull_request` events: without # it, release-drafter would default to `refs/pull/N/merge` (a # virtual ref) which the GitHub API rejects when creating a release, From 24b84830298f0a85f9f2266f4ca3760e5da7c15c Mon Sep 17 00:00:00 2001 From: Scott Murphy Date: Mon, 24 Aug 2026 22:36:43 -0700 Subject: [PATCH 2/3] Stop running the release drafter on pull requests Corrects the commit before this one, which passed `disable-releaser` to an action that has no such input. The workflow's own run said so - "Unexpected input(s) 'disable-releaser'" - and then tried to create a release and failed. The releaser therefore still ran on pull requests, so the concurrency isolation added there did not prevent the concurrent writes it claimed to. The pull request trigger was there so the autolabeler could label pull requests, and the action pinned here cannot do that. release-drafter 7 split labelling into a second entrypoint, release-drafter/release-drafter/autolabeler, and the entrypoint used here has no labelling in it at all - its source mentions labels zero times, against twenty three in the autolabeler's. So a pull request run applied no labels. On a pull request from a fork it could not write a draft either: the token is read only, and the run failed with "Resource not accessible by integration", hidden by continue-on-error. Runs that could only fail also cancelled each other, because every pull request against a release branch computed the same concurrency group - which is how #16183 lost its check twelve seconds in, to an unrelated pull request that pushed after it. Nothing is lost by removing the trigger and the collision goes with it. Drafts are still written on every push to a release branch, which is when a merge can change them, and the concurrency group is a branch again because a branch is the only subject left. Restoring labels needs the autolabeler entrypoint, which is a separate action and not on the ASF approved list - approved_patterns.yml carries only release-drafter/release-drafter@* - so it wants an INFRA request first, and its own change. --- .github/workflows/release-notes.yml | 65 ++++++++++++----------------- 1 file changed, 26 insertions(+), 39 deletions(-) diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index 490328a9d44..1e9061ad990 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -38,16 +38,19 @@ on: push: branches: - '[0-9]+.[0-9]+.x' - # Runs on PRs whose BASE branch is a release branch so the autolabeler can - # apply labels (bug/feature/docs/...). The draft itself is not written here: - # release notes are built from MERGED pull requests, so a run for an open one - # can only rewrite the draft it already wrote. Feature-to-feature PRs (e.g. - # fix/foo -> feat/bar) are intentionally excluded - they cannot affect any - # release. - pull_request: - types: [opened, reopened, synchronize, labeled] - branches: - - '[0-9]+.[0-9]+.x' + # There is deliberately no `pull_request` trigger. It was here so the autolabeler could + # label pull requests, and the action pinned below cannot do that: release-drafter 7 split + # labelling into a second entrypoint, `release-drafter/release-drafter/autolabeler`, and the + # entrypoint used here contains no labelling at all. A pull request run therefore applied no + # labels, and on a pull request from a fork it could not write a draft either - the token is + # read only, so it failed with "Resource not accessible by integration" and was hidden by + # `continue-on-error` below. Runs that could only fail also collided with each other: every + # pull request against a release branch computed the same concurrency group, so one cancelled + # another's check twelve seconds in. + # + # Restoring labels means adding the autolabeler entrypoint, which is a separate action and is + # not on the ASF approved list - `approved_patterns.yml` carries only + # `release-drafter/release-drafter@*` - so it needs an INFRA request first. # Manual recovery: rerun against any branch (e.g. to recreate a draft after # one was accidentally deleted, or to seed an initial draft on a new branch). workflow_dispatch: @@ -66,21 +69,13 @@ on: # uploads assets to the *current published* tag (e.g. v7.0.11). Splitting the # concurrency groups is therefore safe. # -# Keyed on the pull request, not on the branch it targets. Every open PR against -# a release branch used to compute the same group name, so two PRs pushed within -# seconds of each other landed in one slot and `cancel-in-progress` had the later -# one kill the earlier: a red "cancelled" check on a PR that nothing was wrong -# with, and no labels applied to it. Raising several PRs at once - rebasing a -# stack, or merging a branch into each of them - made that routine. -# -# Queueing instead of cancelling would not have fixed it: GitHub keeps one -# pending run per group and cancels the previously pending one, so a sweep of -# ten PRs would still have shown eight cancellations, just of queued runs. -# -# `cancel-in-progress: true` remains right within a single subject: pushing twice -# to the same PR, or twice to a release branch, means only the later run matters. +# `cancel-in-progress: true`: if multiple pushes land on the same branch in +# quick succession, only the latest matters - the latest run sees every PR +# the older one would have seen, so cancelling pending runs is correct. With no +# pull request trigger, a branch is the only subject there is, so nothing shares +# this group with anything unrelated. concurrency: - group: release-drafter-${{ github.event.pull_request.number || github.ref_name }} + group: release-drafter-${{ github.ref_name }} cancel-in-progress: true jobs: @@ -112,7 +107,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -uo pipefail - BRANCH="${{ github.event.pull_request.base.ref || github.ref_name }}" + BRANCH="${{ github.ref_name }}" if [[ ! "$BRANCH" =~ ^([0-9]+)\.([0-9]+)\.x$ ]]; then echo "Branch $BRANCH is not a release branch; no version seed." echo "version=" >> "$GITHUB_OUTPUT" @@ -166,19 +161,11 @@ jobs: # silently produced no draft (e.g. rate-limit exhaustion). continue-on-error: true with: - # A pull request run labels; it does not draft. Release notes are built - # from merged pull requests, so an open one contributes nothing and the - # releaser would rewrite the identical draft - which is also what forced - # every PR to share a concurrency slot, since two runs creating the same - # draft at once is how duplicate drafts appear. Pushes to the release - # branch, which is when a merge actually changes the notes, still draft. - disable-releaser: ${{ github.event_name == 'pull_request' }} - # Explicit `commitish` is critical on `pull_request` events: without - # it, release-drafter would default to `refs/pull/N/merge` (a - # virtual ref) which the GitHub API rejects when creating a release, - # producing the "Validation Failed: target_commitish invalid" error - # historically seen on PRs (see INFRA-27602). - commitish: ${{ github.event.pull_request.base.ref || github.ref_name }} + # Explicit `commitish`, kept from when this also ran on pull requests: it + # defaulted to `refs/pull/N/merge`, a virtual ref the GitHub API rejects + # when creating a release ("Validation Failed: target_commitish invalid", + # see INFRA-27602). Naming the branch is right for a push in any case. + commitish: ${{ github.ref_name }} # Empty for branches that already have a release (release-drafter # treats an empty version as "no override" and resolves the next # version itself). On a release-less branch this is MAJOR.MINOR.0 so @@ -202,7 +189,7 @@ jobs: DRAFT_NAME: ${{ steps.drafter.outputs.name }} DRAFT_URL: ${{ steps.drafter.outputs.html_url }} DRAFT_OUTCOME: ${{ steps.drafter.outcome }} - BRANCH: ${{ github.event.pull_request.base.ref || github.ref_name }} + BRANCH: ${{ github.ref_name }} run: | set -euo pipefail { From 02a2338dd9a39f7f5aeddf225d378036fd680e2e Mon Sep 17 00:00:00 2001 From: Scott Murphy Date: Mon, 24 Aug 2026 22:49:06 -0700 Subject: [PATCH 3/3] Ask only to read the pull requests the notes are built from The job held pull-requests: write for the autolabeler, and there is no autolabeler here any more. What remains reads merged pull requests to build the draft, which the action does through find-recent-merged-pull-requests, so the scope stays at read rather than going altogether: a permissions block is restrictive, and an unnamed scope is denied. --- .github/workflows/release-notes.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index 1e9061ad990..9f3ed1285b5 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -84,8 +84,10 @@ jobs: permissions: # Required to create or update the draft GitHub Release contents: write - # Required for the autolabeler to add labels to PRs - pull-requests: write + # Read, not write: nothing here labels anything any more. The draft is built from merged + # pull requests - the action's find-recent-merged-pull-requests asks for them - and a + # permissions block is restrictive, so the scope has to be named even to read. + pull-requests: read runs-on: ubuntu-latest steps: # Seed an initial version for release branches that have no published