diff --git a/.github/workflows/fork_ci_status.yml b/.github/workflows/fork_ci_status.yml index e1b15c2ad35aa..53b34a5c18b32 100644 --- a/.github/workflows/fork_ci_status.yml +++ b/.github/workflows/fork_ci_status.yml @@ -7,18 +7,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: @@ -34,12 +36,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: @@ -198,8 +201,27 @@ 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. + // 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); + } + 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)) { @@ -299,7 +321,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 1630282eda13d..41c30f81993e1 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -4,36 +4,60 @@ 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 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: + 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 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: | - github.event_name == 'pull_request_target' && - (github.event.action == 'opened' || - github.event.action == 'synchronize') + if: steps.pr.outputs.numbers != '' uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} + pr-number: ${{ steps.pr.outputs.numbers }} 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..b2ad1879895f5 --- /dev/null +++ b/.github/workflows/pull_request_signal.yml @@ -0,0 +1,29 @@ +# 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. +# +# 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 c62dcd33cc936..75b5ba8025822 100644 --- a/.github/workflows/release_version_labeler.yaml +++ b/.github/workflows/release_version_labeler.yaml @@ -11,16 +11,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 5cc62648c9abe..7b06d6522e318 100644 --- a/ci/scripts/release_version_labeler.js +++ b/ci/scripts/release_version_labeler.js @@ -27,10 +27,29 @@ module.exports = async ({ github, context }) => { } } - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - labels: [target_version] - }); + // A squash merge or a merge queue commit maps to the merged pull request. + // 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, + 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 43294fa1d2e9a..3a50def0b134b 100755 --- a/ci/scripts/test_fork_ci_status.cjs +++ b/ci/scripts/test_fork_ci_status.cjs @@ -48,6 +48,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, @@ -79,6 +84,7 @@ async function execute({ workflowState = "active", recentPrs = [pr], slice = [pr], + openPrs = [pr], calls = [], }) { const writes = []; @@ -110,7 +116,11 @@ async function execute({ getWorkflow: async () => ({ data: { state: workflowState } }), }, pulls: { - list: async ({ per_page }) => { + list: async ({ per_page, head, base }) => { + if (head) { + assert.equal(head, "contributor:feature"); + return { data: openPrs.filter((p) => p.base.ref === base) }; + } if (per_page === 1) return { data: [pr], headers: {} }; assert.equal(per_page, 25); return { data: slice }; @@ -128,7 +138,7 @@ async function execute({ { repo: { owner: "apache", repo: "datafusion" }, eventName: event, - payload: { pull_request: pr }, + payload: { workflow_run: workflowRun }, }, { info: () => {} }, Clock @@ -148,13 +158,34 @@ 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 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", + 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);