From 2e5546d35acde626697d2a5f2eac17a78a578eb1 Mon Sep 17 00:00:00 2001 From: comphead Date: Fri, 23 Oct 2026 14:13:57 -0700 Subject: [PATCH 1/4] ci: migrate pull_request_target workflows to workflow_run pull_request_target is disabled by ASF infra policy and GitHub is adding a default policy that blocks it. Add pull_request_signal.yml and trigger labeler.yml and fork_ci_status.yml from its workflow_run. Label merged pull requests from the push to main in release_version_labeler.yaml. --- .github/workflows/fork_ci_status.yml | 39 +++++++++++----- .github/workflows/labeler.yml | 45 ++++++++++++++----- .github/workflows/pull_request_signal.yml | 42 +++++++++++++++++ .../workflows/release_version_labeler.yaml | 7 ++- ci/scripts/release_version_labeler.js | 14 ++++-- ci/scripts/test_fork_ci_status.cjs | 25 +++++++++-- 6 files changed, 140 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/pull_request_signal.yml diff --git a/.github/workflows/fork_ci_status.yml b/.github/workflows/fork_ci_status.yml index 56213480b4a0d..14ee14643d9d7 100644 --- a/.github/workflows/fork_ci_status.yml +++ b/.github/workflows/fork_ci_status.yml @@ -20,18 +20,20 @@ # A pull request from a fork to `main` runs `rust.yml` on the fork. This # workflow mirrors that run as one `Rust (fork)` check on the pull request, so # reviewers see the result without opening the fork. It never checks out or -# runs code from the pull request: on `pull_request_target` it reads the fork's -# public run list and creates the check, and on a schedule it updates the -# active or missing runs of recently updated pull requests plus a rotating -# slice of all open pull requests. The check is informational. The merge +# runs code from the pull request: on `workflow_run` of +# `pull_request_signal.yml` it reads the fork's public run list and creates the +# check (`pull_request_target` is disabled by ASF infra policy), and on a +# schedule it updates the active or missing runs of recently updated pull +# requests plus a rotating slice of all open pull requests. The check is informational. The merge # queue runs the full workflow on the merged result and stays the gate. # # See docs/source/contributor-guide/index.md, "Pull Requests from Forks". name: Fork CI status on: - pull_request_target: - types: [opened, reopened, synchronize] + workflow_run: + workflows: ["Pull request signal"] + types: [requested] schedule: - cron: "*/15 * * * *" workflow_dispatch: @@ -47,12 +49,13 @@ permissions: pull-requests: read concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || 'update' }} - cancel-in-progress: ${{ github.event_name == 'pull_request_target' }} + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name && format('{0}-{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) || 'update' }} + cancel-in-progress: ${{ github.event_name == 'workflow_run' }} jobs: bridge: name: Mirror the fork run + if: github.event_name != 'workflow_run' || github.event.workflow_run.event == 'pull_request' runs-on: ubuntu-slim timeout-minutes: 10 steps: @@ -211,8 +214,24 @@ jobs: core.info(`Created ${CHECK} for ${sha}: ${payload.conclusion || payload.status}`); } + // `workflow_run.pull_requests` is empty for pull requests from + // forks, so find the pull request by its head branch and commit. + async function pullRequestOf(run) { + const { data } = await github.rest.pulls.list({ + ...upstream, + state: 'open', + head: `${run.head_repository.owner.login}:${run.head_branch}`, + }); + return data.find((pr) => pr.head.sha === run.head_sha); + } + async function notify() { - const pr = context.payload.pull_request; + // An outdated commit matches nothing, since a newer run mirrors the new head. + const pr = await pullRequestOf(context.payload.workflow_run); + if (!pr) { + core.info('No open pull request for this commit; nothing to mirror.'); + return; + } // Only fork PRs to main skip jobs upstream; release branches have // no merge queue and keep the full run. if (!isForkPrToMain(pr)) { @@ -312,7 +331,7 @@ jobs: } } - if (context.eventName === 'pull_request_target') { + if (context.eventName === 'workflow_run') { await notify(); } else { await update(); diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index d47bd76c0caa2..06fd5d56ba8ce 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -17,36 +17,57 @@ name: Labeler +# `pull_request_target` is disabled by ASF infra policy. This workflow runs from +# the default branch when `pull_request_signal.yml` starts, so it never runs the +# code of a pull request. See that file for details. concurrency: - group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} + group: ${{ github.repository }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}-${{ github.workflow }} cancel-in-progress: true on: - pull_request_target: - types: - - opened - - edited - - synchronize + workflow_run: + workflows: ["Pull request signal"] + types: [requested] + +permissions: {} jobs: process: name: Process runs-on: ubuntu-slim - # only run for users whose permissions allow them to update PRs - # otherwise labeler is failing: + if: github.event.workflow_run.event == 'pull_request' + # The token of a `workflow_run` workflow can update PRs from forks. + # Previously `pull_request_target` was used for the same reason, see # https://github.com/apache/datafusion/issues/3743 permissions: contents: read pull-requests: write steps: + # `workflow_run.pull_requests` is empty for pull requests from forks, so + # find the pull request by its head branch and commit. An outdated commit + # matches nothing, since a newer run labels the new head. + - name: Find the pull request + id: pr + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const run = context.payload.workflow_run; + const { data } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${run.head_repository.owner.login}:${run.head_branch}`, + }); + const pr = data.find((pr) => pr.head.sha === run.head_sha); + if (pr) core.setOutput('number', String(pr.number)); + else core.info(`No open pull request for ${run.head_sha}`); + - name: Assign GitHub labels - if: | - github.event_name == 'pull_request_target' && - (github.event.action == 'opened' || - github.event.action == 'synchronize') + if: steps.pr.outputs.number != '' uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} + pr-number: ${{ steps.pr.outputs.number }} configuration-path: .github/workflows/labeler/labeler-config.yml sync-labels: true diff --git a/.github/workflows/pull_request_signal.yml b/.github/workflows/pull_request_signal.yml new file mode 100644 index 0000000000000..78be1f0d5a769 --- /dev/null +++ b/.github/workflows/pull_request_signal.yml @@ -0,0 +1,42 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Signal for the workflows that need a write token on a pull request. +# +# ASF infra policy disables `pull_request_target` +# (https://infra.apache.org/github-actions-policy.html), and GitHub is adding a +# default policy that blocks it. The `workflow_run` workflows below run from the +# default branch with a write token and never check out or run pull request +# code. They trigger on the `requested` event of this workflow, which GitHub +# sends at once for every run, even for a first-time contributor whose run waits +# for approval: +# - labeler.yml +# - fork_ci_status.yml +name: Pull request signal + +on: + pull_request: + types: [opened, reopened, synchronize] + +permissions: {} + +jobs: + signal: + name: Signal pull request update + runs-on: ubuntu-slim + steps: + - run: echo "This run triggers the workflow_run workflows that handle the pull request." diff --git a/.github/workflows/release_version_labeler.yaml b/.github/workflows/release_version_labeler.yaml index 1e44ad842c1d5..6dd3b1f275849 100644 --- a/.github/workflows/release_version_labeler.yaml +++ b/.github/workflows/release_version_labeler.yaml @@ -24,16 +24,15 @@ concurrency: group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} cancel-in-progress: true +# `pull_request_target` is disabled by ASF infra policy, so label on the push of +# the merge commit to `main` and look up the merged pull request from it. on: - pull_request_target: - types: - - closed + push: branches: - main jobs: label: - if: github.event.pull_request.merged == true runs-on: ubuntu-slim permissions: pull-requests: write diff --git a/ci/scripts/release_version_labeler.js b/ci/scripts/release_version_labeler.js index 78d17d5671e0a..5c792366378e5 100644 --- a/ci/scripts/release_version_labeler.js +++ b/ci/scripts/release_version_labeler.js @@ -40,10 +40,18 @@ module.exports = async ({ github, context }) => { } } - await github.rest.issues.addLabels({ + // A squash merge or a merge queue commit maps to the merged pull request. + const { data: pulls } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - labels: [target_version] + commit_sha: context.sha }); + for (const pull of pulls.filter((pull) => pull.merged_at && pull.base.ref === 'main')) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pull.number, + labels: [target_version] + }); + } }; diff --git a/ci/scripts/test_fork_ci_status.cjs b/ci/scripts/test_fork_ci_status.cjs index 46e55c1776941..c7569e8c7f72b 100755 --- a/ci/scripts/test_fork_ci_status.cjs +++ b/ci/scripts/test_fork_ci_status.cjs @@ -61,6 +61,11 @@ const pr = { }, base: { ref: "main" }, }; +const workflowRun = { + head_sha: sha, + head_branch: "feature", + head_repository: { owner: { login: "contributor" } }, +}; const run = (id, event = "push", conclusion = "success", run_attempt = 1) => ({ id, event, @@ -92,6 +97,7 @@ async function execute({ workflowState = "active", recentPrs = [pr], slice = [pr], + openPrs = [pr], calls = [], }) { const writes = []; @@ -123,7 +129,11 @@ async function execute({ getWorkflow: async () => ({ data: { state: workflowState } }), }, pulls: { - list: async ({ per_page }) => { + list: async ({ per_page, head }) => { + if (head) { + assert.equal(head, "contributor:feature"); + return { data: openPrs }; + } if (per_page === 1) return { data: [pr], headers: {} }; assert.equal(per_page, 25); return { data: slice }; @@ -141,7 +151,7 @@ async function execute({ { repo: { owner: "apache", repo: "datafusion" }, eventName: event, - payload: { pull_request: pr }, + payload: { workflow_run: workflowRun }, }, { info: () => {} }, Clock @@ -161,13 +171,22 @@ function assertRun(write, method, expected) { test("notification selects a newer manual run instead of a push run", async () => { const latest = run(2, "workflow_dispatch"); const writes = await execute({ - event: "pull_request_target", + event: "workflow_run", runs: [latest, run(1)], }); assert.equal(writes.length, 1); assertRun(writes[0], "create", latest); }); +test("notification skips a commit that is no longer a pull request head", async () => { + const writes = await execute({ + event: "workflow_run", + openPrs: [{ ...pr, head: { ...pr.head, sha: "b".repeat(40) } }], + runs: [run(1)], + }); + assert.deepEqual(writes, []); +}); + for (const conclusion of ["cancelled", "failure"]) { test(`sweep replaces a ${conclusion} push run with a newer manual run`, async () => { const previous = run(1, "push", conclusion); From 078a414ff86fc8fef02a5c8e158901d3f062c9ba Mon Sep 17 00:00:00 2001 From: comphead Date: Tue, 3 Nov 2026 09:01:43 -0800 Subject: [PATCH 2/4] ci: handle several pull requests from one head branch Label every pull request that matches the head commit, and mirror the fork check only for the pull request to main. --- .github/workflows/fork_ci_status.yml | 3 +++ .github/workflows/labeler.yml | 17 ++++++++++------- ci/scripts/test_fork_ci_status.cjs | 16 ++++++++++++++-- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/.github/workflows/fork_ci_status.yml b/.github/workflows/fork_ci_status.yml index 14ee14643d9d7..7e05d437d0c9d 100644 --- a/.github/workflows/fork_ci_status.yml +++ b/.github/workflows/fork_ci_status.yml @@ -216,10 +216,13 @@ jobs: // `workflow_run.pull_requests` is empty for pull requests from // forks, so find the pull request by its head branch and commit. + // The same branch can also target a release branch, which gets no + // mirrored check, so list only the pull requests to `main`. async function pullRequestOf(run) { const { data } = await github.rest.pulls.list({ ...upstream, state: 'open', + base: 'main', head: `${run.head_repository.owner.login}:${run.head_branch}`, }); return data.find((pr) => pr.head.sha === run.head_sha); diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 06fd5d56ba8ce..53d23d742d0e2 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -44,9 +44,10 @@ jobs: pull-requests: write steps: # `workflow_run.pull_requests` is empty for pull requests from forks, so - # find the pull request by its head branch and commit. An outdated commit - # matches nothing, since a newer run labels the new head. - - name: Find the pull request + # find the pull requests by their head branch and commit. The same branch + # can target `main` and a release branch, so label every match. An + # outdated commit matches nothing, since a newer run labels the new head. + - name: Find the pull requests id: pr uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: @@ -58,16 +59,18 @@ jobs: state: 'open', head: `${run.head_repository.owner.login}:${run.head_branch}`, }); - const pr = data.find((pr) => pr.head.sha === run.head_sha); - if (pr) core.setOutput('number', String(pr.number)); + const numbers = data + .filter((pr) => pr.head.sha === run.head_sha) + .map((pr) => String(pr.number)); + if (numbers.length) core.setOutput('numbers', numbers.join('\n')); else core.info(`No open pull request for ${run.head_sha}`); - name: Assign GitHub labels - if: steps.pr.outputs.number != '' + if: steps.pr.outputs.numbers != '' uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - pr-number: ${{ steps.pr.outputs.number }} + pr-number: ${{ steps.pr.outputs.numbers }} configuration-path: .github/workflows/labeler/labeler-config.yml sync-labels: true diff --git a/ci/scripts/test_fork_ci_status.cjs b/ci/scripts/test_fork_ci_status.cjs index c7569e8c7f72b..9199a3ef07a2c 100755 --- a/ci/scripts/test_fork_ci_status.cjs +++ b/ci/scripts/test_fork_ci_status.cjs @@ -129,10 +129,10 @@ async function execute({ getWorkflow: async () => ({ data: { state: workflowState } }), }, pulls: { - list: async ({ per_page, head }) => { + list: async ({ per_page, head, base }) => { if (head) { assert.equal(head, "contributor:feature"); - return { data: openPrs }; + return { data: openPrs.filter((p) => p.base.ref === base) }; } if (per_page === 1) return { data: [pr], headers: {} }; assert.equal(per_page, 25); @@ -178,6 +178,18 @@ test("notification selects a newer manual run instead of a push run", async () = assertRun(writes[0], "create", latest); }); +test("notification mirrors the pull request to main when a newer one targets a release branch", async () => { + const release = { ...pr, number: 2, base: { ref: "branch-55" } }; + const latest = run(2, "workflow_dispatch"); + const writes = await execute({ + event: "workflow_run", + openPrs: [release, pr], + runs: [latest], + }); + assert.equal(writes.length, 1); + assertRun(writes[0], "create", latest); +}); + test("notification skips a commit that is no longer a pull request head", async () => { const writes = await execute({ event: "workflow_run", From 2f566780e07628f5a3836562a4eedb9dd9a28a08 Mon Sep 17 00:00:00 2001 From: comphead Date: Tue, 3 Nov 2026 13:14:57 -0800 Subject: [PATCH 3/4] ci: retry the merged pull request lookup in the release version labeler The merged pull request can lag the push to main by a few seconds. Retry up to three times, and fail the job when no merged pull request is found. --- ci/scripts/release_version_labeler.js | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/ci/scripts/release_version_labeler.js b/ci/scripts/release_version_labeler.js index 5c792366378e5..40c2d6bcac372 100644 --- a/ci/scripts/release_version_labeler.js +++ b/ci/scripts/release_version_labeler.js @@ -41,12 +41,23 @@ module.exports = async ({ github, context }) => { } // A squash merge or a merge queue commit maps to the merged pull request. - const { data: pulls } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ - owner: context.repo.owner, - repo: context.repo.repo, - commit_sha: context.sha - }); - for (const pull of pulls.filter((pull) => pull.merged_at && pull.base.ref === 'main')) { + // The pull request can lag the push by a few seconds, so retry. + let pulls = []; + for (let attempt = 0; attempt < 3 && pulls.length === 0; attempt++) { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + const { data } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.sha + }); + pulls = data.filter((pull) => pull.merged_at && pull.base.ref === 'main'); + } + if (pulls.length === 0) { + throw new Error(`No merged pull request found for ${context.sha}`); + } + for (const pull of pulls) { await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, From cb1509a5079f5d4f9a01bdc1cdcecaaeee3cab14 Mon Sep 17 00:00:00 2001 From: comphead Date: Tue, 3 Nov 2026 13:46:51 -0800 Subject: [PATCH 4/4] ci: use the SPDX license header in pull_request_signal.yml --- .github/workflows/pull_request_signal.yml | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/.github/workflows/pull_request_signal.yml b/.github/workflows/pull_request_signal.yml index 78be1f0d5a769..b2ad1879895f5 100644 --- a/.github/workflows/pull_request_signal.yml +++ b/.github/workflows/pull_request_signal.yml @@ -1,19 +1,6 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: See the NOTICE file distributed with this work for additional information regarding copyright ownership +# SPDX-FileContributor: Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements # Signal for the workflows that need a write token on a pull request. #