|
| 1 | +# Licensed to the Apache Software Foundation (ASF) under one |
| 2 | +# or more contributor license agreements. See the NOTICE file |
| 3 | +# distributed with this work for additional information |
| 4 | +# regarding copyright ownership. The ASF licenses this file |
| 5 | +# to you under the Apache License, Version 2.0 (the |
| 6 | +# "License"); you may not use this file except in compliance |
| 7 | +# with the License. You may obtain a copy of the License at |
| 8 | +# |
| 9 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 10 | +# |
| 11 | +# Unless required by applicable law or agreed to in writing, |
| 12 | +# software distributed under the License is distributed on an |
| 13 | +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| 14 | +# KIND, either express or implied. See the License for the |
| 15 | +# specific language governing permissions and limitations |
| 16 | +# under the License. |
| 17 | + |
| 18 | +# # Fork CI status |
| 19 | +# |
| 20 | +# A pull request from a fork to `main` runs `rust.yml` on the fork. This |
| 21 | +# workflow mirrors that run as one `Rust (fork)` check on the pull request, so |
| 22 | +# reviewers see the result without opening the fork. It never checks out or |
| 23 | +# runs code from the pull request: on `pull_request_target` it reads the fork's |
| 24 | +# public run list and creates the check, and on a schedule it updates the |
| 25 | +# active or missing runs of recently updated pull requests plus a rotating |
| 26 | +# slice of all open pull requests. The check is informational. The merge |
| 27 | +# queue runs the full workflow on the merged result and stays the gate. |
| 28 | +# |
| 29 | +# See docs/source/contributor-guide/index.md, "Pull Requests from Forks". |
| 30 | +name: Fork CI status |
| 31 | + |
| 32 | +on: |
| 33 | + pull_request_target: |
| 34 | + types: [opened, reopened, synchronize] |
| 35 | + schedule: |
| 36 | + - cron: "*/15 * * * *" |
| 37 | + workflow_dispatch: |
| 38 | + inputs: |
| 39 | + hours: |
| 40 | + description: "Update every pull request updated within this many hours" |
| 41 | + type: number |
| 42 | + default: 6 |
| 43 | + |
| 44 | +permissions: |
| 45 | + actions: read |
| 46 | + checks: write |
| 47 | + pull-requests: read |
| 48 | + |
| 49 | +concurrency: |
| 50 | + group: ${{ github.workflow }}-${{ github.event.pull_request.number || 'update' }} |
| 51 | + cancel-in-progress: ${{ github.event_name == 'pull_request_target' }} |
| 52 | + |
| 53 | +jobs: |
| 54 | + bridge: |
| 55 | + name: Mirror the fork run |
| 56 | + runs-on: ubuntu-slim |
| 57 | + timeout-minutes: 10 |
| 58 | + steps: |
| 59 | + - name: Create or update the check |
| 60 | + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 |
| 61 | + env: |
| 62 | + UPDATE_HOURS: ${{ inputs.hours || 6 }} |
| 63 | + with: |
| 64 | + script: | |
| 65 | + const CHECK = 'Rust (fork)'; |
| 66 | + const WORKFLOW = 'rust.yml'; |
| 67 | + const upstream = { owner: context.repo.owner, repo: context.repo.repo }; |
| 68 | + const GUIDE = 'https://datafusion.apache.org/contributor-guide/#pull-requests-from-forks'; |
| 69 | + // Older pull requests are revisited this many at a time, so a rerun |
| 70 | + // or a late run on a quiet pull request is mirrored within hours. |
| 71 | + const SWEEP_SIZE = 25; |
| 72 | +
|
| 73 | + // A check run accepts a fixed set of conclusions; a workflow run can |
| 74 | + // report others, such as startup_failure. |
| 75 | + const CONCLUSIONS = new Set([ |
| 76 | + 'action_required', 'cancelled', 'failure', 'neutral', |
| 77 | + 'skipped', 'stale', 'success', 'timed_out', |
| 78 | + ]); |
| 79 | + const conclusionOf = (run) => |
| 80 | + CONCLUSIONS.has(run.conclusion) ? run.conclusion : 'failure'; |
| 81 | + const statusOf = (run) => { |
| 82 | + if (run.status === 'completed') return 'completed'; |
| 83 | + return run.status === 'in_progress' ? 'in_progress' : 'queued'; |
| 84 | + }; |
| 85 | + const isForkPrToMain = (pr) => |
| 86 | + Boolean(pr.head.repo) && pr.head.repo.fork === true && pr.base.ref === 'main'; |
| 87 | +
|
| 88 | + const HOW_UPDATED = |
| 89 | + 'Active or missing runs on recently updated pull requests are checked every 15 minutes. ' + |
| 90 | + 'Completed runs and older pull requests are checked by a rotating sweep, which can take a few hours. ' + |
| 91 | + 'The merge queue runs the full workflow on the merged result before this pull request merges.'; |
| 92 | +
|
| 93 | + // The check for a run. `text` is read back by the scheduled update. |
| 94 | + const runPayload = (fork, sha, run) => { |
| 95 | + const status = statusOf(run); |
| 96 | + return { |
| 97 | + status, |
| 98 | + ...(status === 'completed' ? { conclusion: conclusionOf(run) } : {}), |
| 99 | + details_url: run.html_url, |
| 100 | + output: { |
| 101 | + title: `Fork run ${status === 'completed' ? run.conclusion : status}`, |
| 102 | + summary: [ |
| 103 | + `[Rust workflow run on ${fork.owner}/${fork.repo}](${run.html_url}) for this commit.`, |
| 104 | + '', |
| 105 | + 'If the fork run failed for reasons unrelated to this pull request, rerun it on the fork.', |
| 106 | + HOW_UPDATED, |
| 107 | + ].join('\n'), |
| 108 | + text: JSON.stringify({ |
| 109 | + owner: fork.owner, |
| 110 | + repo: fork.repo, |
| 111 | + head_sha: sha, |
| 112 | + run_id: run.id, |
| 113 | + run_attempt: run.run_attempt, |
| 114 | + }), |
| 115 | + }, |
| 116 | + }; |
| 117 | + }; |
| 118 | +
|
| 119 | + // The check when the fork has no run for the commit. The workflow's |
| 120 | + // state on the fork says whether workflows are enabled there; a |
| 121 | + // push that changes only documentation or Markdown does not start |
| 122 | + // rust.yml even when they are. The scheduled update keeps looking |
| 123 | + // for a run in every case. |
| 124 | + const missingPayload = (fork, sha, state) => { |
| 125 | + const disabled = typeof state === 'string' && state.startsWith('disabled'); |
| 126 | + const active = state === 'active'; |
| 127 | + const advice = disabled |
| 128 | + ? [ |
| 129 | + '1. Enable workflows on your fork: open its **Actions** tab and confirm the prompt.', |
| 130 | + '2. Push again. A push that changes only documentation or Markdown does not start', |
| 131 | + ' the Rust workflow.', |
| 132 | + ] |
| 133 | + : active |
| 134 | + ? [ |
| 135 | + 'A push that changes only documentation or Markdown does not start the Rust', |
| 136 | + 'workflow, and a run can take a moment to appear. This check keeps looking for one.', |
| 137 | + ] |
| 138 | + : [ |
| 139 | + 'GitHub reports no Rust workflow on the fork, which usually means workflows are not', |
| 140 | + 'enabled there. Enable them on the fork\'s **Actions** tab and push again. A push that', |
| 141 | + 'changes only documentation or Markdown does not start the Rust workflow.', |
| 142 | + ]; |
| 143 | + return { |
| 144 | + status: 'completed', |
| 145 | + conclusion: disabled ? 'action_required' : active ? 'skipped' : 'neutral', |
| 146 | + output: { |
| 147 | + title: disabled ? 'Workflows are disabled on the fork' : 'No fork run for this commit', |
| 148 | + summary: [ |
| 149 | + `No Rust workflow run was found on ${fork.owner}/${fork.repo} for ${sha}.`, |
| 150 | + '', |
| 151 | + ...advice, |
| 152 | + '', |
| 153 | + HOW_UPDATED, |
| 154 | + '', |
| 155 | + `See ${GUIDE}`, |
| 156 | + ].join('\n'), |
| 157 | + text: JSON.stringify({ owner: fork.owner, repo: fork.repo, head_sha: sha, run_id: null }), |
| 158 | + }, |
| 159 | + }; |
| 160 | + }; |
| 161 | +
|
| 162 | + // The fork creates its run a few seconds after the push. |
| 163 | + async function findRun(fork, sha, attempts) { |
| 164 | + for (let attempt = 0; attempt < attempts; attempt++) { |
| 165 | + try { |
| 166 | + const { data } = await github.rest.actions.listWorkflowRuns({ |
| 167 | + owner: fork.owner, |
| 168 | + repo: fork.repo, |
| 169 | + workflow_id: WORKFLOW, |
| 170 | + head_sha: sha, |
| 171 | + per_page: 10, |
| 172 | + }); |
| 173 | + // A newer manual run can replace a cancelled push run. |
| 174 | + const run = data.workflow_runs[0]; |
| 175 | + if (run) return run; |
| 176 | + } catch (error) { |
| 177 | + // 404 when the fork has Actions disabled or lacks the workflow. |
| 178 | + core.info(`No run list for ${fork.owner}/${fork.repo}: ${error.message}`); |
| 179 | + } |
| 180 | + if (attempt + 1 < attempts) { |
| 181 | + await new Promise((resolve) => setTimeout(resolve, 5000)); |
| 182 | + } |
| 183 | + } |
| 184 | + return null; |
| 185 | + } |
| 186 | +
|
| 187 | + // `active`, or `disabled_fork` until the owner enables workflows; |
| 188 | + // null when the workflow cannot be read. |
| 189 | + async function workflowState(fork) { |
| 190 | + try { |
| 191 | + const { data } = await github.rest.actions.getWorkflow({ |
| 192 | + owner: fork.owner, |
| 193 | + repo: fork.repo, |
| 194 | + workflow_id: WORKFLOW, |
| 195 | + }); |
| 196 | + return data.state || null; |
| 197 | + } catch (error) { |
| 198 | + core.info(`No workflow state for ${fork.owner}/${fork.repo}: ${error.message}`); |
| 199 | + return null; |
| 200 | + } |
| 201 | + } |
| 202 | +
|
| 203 | + async function createCheck(pr, attempts) { |
| 204 | + const fork = { owner: pr.head.repo.owner.login, repo: pr.head.repo.name }; |
| 205 | + const sha = pr.head.sha; |
| 206 | + const run = await findRun(fork, sha, attempts); |
| 207 | + const payload = run |
| 208 | + ? runPayload(fork, sha, run) |
| 209 | + : missingPayload(fork, sha, await workflowState(fork)); |
| 210 | + await github.rest.checks.create({ ...upstream, name: CHECK, head_sha: sha, ...payload }); |
| 211 | + core.info(`Created ${CHECK} for ${sha}: ${payload.conclusion || payload.status}`); |
| 212 | + } |
| 213 | +
|
| 214 | + async function notify() { |
| 215 | + const pr = context.payload.pull_request; |
| 216 | + // Only fork PRs to main skip jobs upstream; release branches have |
| 217 | + // no merge queue and keep the full run. |
| 218 | + if (!isForkPrToMain(pr)) { |
| 219 | + core.info('Not a fork pull request to main; nothing to mirror.'); |
| 220 | + return; |
| 221 | + } |
| 222 | + await createCheck(pr, 12); |
| 223 | + } |
| 224 | +
|
| 225 | + // Discover the latest run again, since a manual run can replace the |
| 226 | + // recorded run, and the fork can delete old runs. |
| 227 | + async function syncCheck(pr, includeCompleted = true) { |
| 228 | + const { data } = await github.rest.checks.listForRef({ |
| 229 | + ...upstream, |
| 230 | + ref: pr.head.sha, |
| 231 | + check_name: CHECK, |
| 232 | + per_page: 5, |
| 233 | + }); |
| 234 | + const check = data.check_runs[0]; |
| 235 | + if (!check) { |
| 236 | + // Recover if the initial notification failed before creating a check. |
| 237 | + await createCheck(pr, 1); |
| 238 | + return; |
| 239 | + } |
| 240 | + if (!check.output || !check.output.text) return; |
| 241 | + let params; |
| 242 | + try { |
| 243 | + params = JSON.parse(check.output.text); |
| 244 | + } catch { |
| 245 | + return; |
| 246 | + } |
| 247 | + // Completed runs only need the rotating sweep to discover reruns. |
| 248 | + // A completed check with no run must keep looking for a late run. |
| 249 | + if (!includeCompleted && check.status === 'completed' && params.run_id) return; |
| 250 | + const fork = { owner: pr.head.repo.owner.login, repo: pr.head.repo.name }; |
| 251 | + const sha = pr.head.sha; |
| 252 | + const run = await findRun(fork, sha, 1); |
| 253 | + if (!run) return; |
| 254 | + const payload = runPayload(fork, sha, run); |
| 255 | + if ( |
| 256 | + payload.status === check.status && |
| 257 | + (payload.conclusion || null) === check.conclusion && |
| 258 | + run.id === params.run_id && |
| 259 | + run.run_attempt === params.run_attempt |
| 260 | + ) { |
| 261 | + return; |
| 262 | + } |
| 263 | + await github.rest.checks.update({ ...upstream, check_run_id: check.id, ...payload }); |
| 264 | + core.info(`#${pr.number}: ${check.status}/${check.conclusion} -> ${payload.status}/${payload.conclusion || ''}`); |
| 265 | + } |
| 266 | +
|
| 267 | + const listing = { ...upstream, state: 'open', sort: 'updated', direction: 'desc' }; |
| 268 | +
|
| 269 | + // The number of open pull requests, from the last-page link of a |
| 270 | + // one-item page: one request, whatever the count. |
| 271 | + async function openPullRequestCount() { |
| 272 | + const response = await github.rest.pulls.list({ ...listing, per_page: 1 }); |
| 273 | + const last = /[?&]page=(\d+)>;\s*rel="last"/.exec(response.headers.link || ''); |
| 274 | + return last ? Number(last[1]) : response.data.length; |
| 275 | + } |
| 276 | +
|
| 277 | + async function update() { |
| 278 | + const hours = Number(process.env.UPDATE_HOURS) || 6; |
| 279 | + const cutoff = Date.now() - hours * 3600 * 1000; |
| 280 | + const seen = new Set(); |
| 281 | +
|
| 282 | + // Select the sweep first so a recent completed PR in this slice |
| 283 | + // is checked once, even though it also appears in the window. |
| 284 | + const slices = Math.max(1, Math.ceil((await openPullRequestCount()) / SWEEP_SIZE)); |
| 285 | + const slot = Math.floor(Date.now() / (15 * 60 * 1000)); |
| 286 | + const page = (slot % slices) + 1; |
| 287 | + const { data: slice } = await github.rest.pulls.list({ ...listing, per_page: SWEEP_SIZE, page }); |
| 288 | + const sweepNumbers = new Set(slice.map((pr) => pr.number)); |
| 289 | +
|
| 290 | + // Pull requests updated within the window, newest first. Stop at |
| 291 | + // the window: GITHUB_TOKEN allows 1,000 requests per hour per |
| 292 | + // repository. |
| 293 | + const pages = github.paginate.iterator(github.rest.pulls.list, { ...listing, per_page: 100 }); |
| 294 | + for await (const page of pages) { |
| 295 | + let stale = false; |
| 296 | + for (const pr of page.data) { |
| 297 | + if (new Date(pr.updated_at).getTime() < cutoff) { |
| 298 | + stale = true; |
| 299 | + break; |
| 300 | + } |
| 301 | + seen.add(pr.number); |
| 302 | + if (isForkPrToMain(pr)) await syncCheck(pr, sweepNumbers.has(pr.number)); |
| 303 | + } |
| 304 | + if (stale) break; |
| 305 | + } |
| 306 | +
|
| 307 | + // Check the rest of the slice, including older pull requests. |
| 308 | + // A fork rerun does not update the upstream pull request. |
| 309 | + for (const pr of slice) { |
| 310 | + if (seen.has(pr.number) || !isForkPrToMain(pr)) continue; |
| 311 | + await syncCheck(pr); |
| 312 | + } |
| 313 | + } |
| 314 | +
|
| 315 | + if (context.eventName === 'pull_request_target') { |
| 316 | + await notify(); |
| 317 | + } else { |
| 318 | + await update(); |
| 319 | + } |
0 commit comments