Repository navigation
refactor: Centralize float normalization #435
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Licensed to the Apache Software Foundation (ASF) under one | ||
|
Check warning on line 1 in .github/workflows/fork_ci_status.yml
|
||
| # 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. | ||
| # # Fork CI status | ||
| # | ||
| # 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 | ||
| # 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] | ||
| schedule: | ||
| - cron: "*/15 * * * *" | ||
| workflow_dispatch: | ||
| inputs: | ||
| hours: | ||
| description: "Update every pull request updated within this many hours" | ||
| type: number | ||
| default: 6 | ||
| permissions: | ||
| actions: read | ||
| checks: write | ||
| pull-requests: read | ||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || 'update' }} | ||
| cancel-in-progress: ${{ github.event_name == 'pull_request_target' }} | ||
| jobs: | ||
| bridge: | ||
| name: Mirror the fork run | ||
| runs-on: ubuntu-slim | ||
| timeout-minutes: 10 | ||
| steps: | ||
| - name: Create or update the check | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
| env: | ||
| UPDATE_HOURS: ${{ inputs.hours || 6 }} | ||
| with: | ||
| script: | | ||
| const CHECK = 'Rust (fork)'; | ||
| const WORKFLOW = 'rust.yml'; | ||
| const upstream = { owner: context.repo.owner, repo: context.repo.repo }; | ||
| const GUIDE = 'https://datafusion.apache.org/contributor-guide/#pull-requests-from-forks'; | ||
| // Older pull requests are revisited this many at a time, so a rerun | ||
| // or a late run on a quiet pull request is mirrored within hours. | ||
| const SWEEP_SIZE = 25; | ||
| // A check run accepts a fixed set of conclusions; a workflow run can | ||
| // report others, such as startup_failure. | ||
| const CONCLUSIONS = new Set([ | ||
| 'action_required', 'cancelled', 'failure', 'neutral', | ||
| 'skipped', 'stale', 'success', 'timed_out', | ||
| ]); | ||
| const conclusionOf = (run) => | ||
| CONCLUSIONS.has(run.conclusion) ? run.conclusion : 'failure'; | ||
| const statusOf = (run) => { | ||
| if (run.status === 'completed') return 'completed'; | ||
| return run.status === 'in_progress' ? 'in_progress' : 'queued'; | ||
| }; | ||
| const isForkPrToMain = (pr) => | ||
| Boolean(pr.head.repo) && pr.head.repo.fork === true && pr.base.ref === 'main'; | ||
| const HOW_UPDATED = | ||
| 'Active or missing runs on recently updated pull requests are checked every 15 minutes. ' + | ||
| 'Completed runs and older pull requests are checked by a rotating sweep, which can take a few hours. ' + | ||
| 'The merge queue runs the full workflow on the merged result before this pull request merges.'; | ||
| // The check for a run. `text` is read back by the scheduled update. | ||
| const runPayload = (fork, sha, run) => { | ||
| const status = statusOf(run); | ||
| return { | ||
| status, | ||
| ...(status === 'completed' ? { conclusion: conclusionOf(run) } : {}), | ||
| details_url: run.html_url, | ||
| output: { | ||
| title: `Fork run ${status === 'completed' ? run.conclusion : status}`, | ||
| summary: [ | ||
| `[Rust workflow run on ${fork.owner}/${fork.repo}](${run.html_url}) for this commit.`, | ||
| '', | ||
| 'If the fork run failed for reasons unrelated to this pull request, rerun it on the fork.', | ||
| HOW_UPDATED, | ||
| ].join('\n'), | ||
| text: JSON.stringify({ | ||
| owner: fork.owner, | ||
| repo: fork.repo, | ||
| head_sha: sha, | ||
| run_id: run.id, | ||
| run_attempt: run.run_attempt, | ||
| }), | ||
| }, | ||
| }; | ||
| }; | ||
| // The check when the fork has no run for the commit. The workflow's | ||
| // state on the fork says whether workflows are enabled there; a | ||
| // push that changes only documentation or Markdown does not start | ||
| // rust.yml even when they are. The scheduled update keeps looking | ||
| // for a run in every case. | ||
| const missingPayload = (fork, sha, state) => { | ||
| const disabled = typeof state === 'string' && state.startsWith('disabled'); | ||
| const active = state === 'active'; | ||
| const advice = disabled | ||
| ? [ | ||
| '1. Enable workflows on your fork: open its **Actions** tab and confirm the prompt.', | ||
| '2. Push again. A push that changes only documentation or Markdown does not start', | ||
| ' the Rust workflow.', | ||
| ] | ||
| : active | ||
| ? [ | ||
| 'A push that changes only documentation or Markdown does not start the Rust', | ||
| 'workflow, and a run can take a moment to appear. This check keeps looking for one.', | ||
| ] | ||
| : [ | ||
| 'GitHub reports no Rust workflow on the fork, which usually means workflows are not', | ||
| 'enabled there. Enable them on the fork\'s **Actions** tab and push again. A push that', | ||
| 'changes only documentation or Markdown does not start the Rust workflow.', | ||
| ]; | ||
| return { | ||
| status: 'completed', | ||
| conclusion: disabled ? 'action_required' : active ? 'skipped' : 'neutral', | ||
| output: { | ||
| title: disabled ? 'Workflows are disabled on the fork' : 'No fork run for this commit', | ||
| summary: [ | ||
| `No Rust workflow run was found on ${fork.owner}/${fork.repo} for ${sha}.`, | ||
| '', | ||
| ...advice, | ||
| '', | ||
| HOW_UPDATED, | ||
| '', | ||
| `See ${GUIDE}`, | ||
| ].join('\n'), | ||
| text: JSON.stringify({ owner: fork.owner, repo: fork.repo, head_sha: sha, run_id: null }), | ||
| }, | ||
| }; | ||
| }; | ||
| // The fork creates its run a few seconds after the push. | ||
| async function findRun(fork, sha, attempts) { | ||
| for (let attempt = 0; attempt < attempts; attempt++) { | ||
| try { | ||
| const { data } = await github.rest.actions.listWorkflowRuns({ | ||
| owner: fork.owner, | ||
| repo: fork.repo, | ||
| workflow_id: WORKFLOW, | ||
| head_sha: sha, | ||
| per_page: 10, | ||
| }); | ||
| // A newer manual run can replace a cancelled push run. | ||
| const run = data.workflow_runs[0]; | ||
| if (run) return run; | ||
| } catch (error) { | ||
| // 404 when the fork has Actions disabled or lacks the workflow. | ||
| core.info(`No run list for ${fork.owner}/${fork.repo}: ${error.message}`); | ||
| } | ||
| if (attempt + 1 < attempts) { | ||
| await new Promise((resolve) => setTimeout(resolve, 5000)); | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| // `active`, or `disabled_fork` until the owner enables workflows; | ||
| // null when the workflow cannot be read. | ||
| async function workflowState(fork) { | ||
| try { | ||
| const { data } = await github.rest.actions.getWorkflow({ | ||
| owner: fork.owner, | ||
| repo: fork.repo, | ||
| workflow_id: WORKFLOW, | ||
| }); | ||
| return data.state || null; | ||
| } catch (error) { | ||
| core.info(`No workflow state for ${fork.owner}/${fork.repo}: ${error.message}`); | ||
| return null; | ||
| } | ||
| } | ||
| async function createCheck(pr, attempts) { | ||
| const fork = { owner: pr.head.repo.owner.login, repo: pr.head.repo.name }; | ||
| const sha = pr.head.sha; | ||
| const run = await findRun(fork, sha, attempts); | ||
| const payload = run | ||
| ? runPayload(fork, sha, run) | ||
| : missingPayload(fork, sha, await workflowState(fork)); | ||
| await github.rest.checks.create({ ...upstream, name: CHECK, head_sha: sha, ...payload }); | ||
| core.info(`Created ${CHECK} for ${sha}: ${payload.conclusion || payload.status}`); | ||
| } | ||
| async function notify() { | ||
| const pr = context.payload.pull_request; | ||
| // Only fork PRs to main skip jobs upstream; release branches have | ||
| // no merge queue and keep the full run. | ||
| if (!isForkPrToMain(pr)) { | ||
| core.info('Not a fork pull request to main; nothing to mirror.'); | ||
| return; | ||
| } | ||
| await createCheck(pr, 12); | ||
| } | ||
| // Discover the latest run again, since a manual run can replace the | ||
| // recorded run, and the fork can delete old runs. | ||
| async function syncCheck(pr, includeCompleted = true) { | ||
| const { data } = await github.rest.checks.listForRef({ | ||
| ...upstream, | ||
| ref: pr.head.sha, | ||
| check_name: CHECK, | ||
| per_page: 5, | ||
| }); | ||
| const check = data.check_runs[0]; | ||
| if (!check) { | ||
| // Recover if the initial notification failed before creating a check. | ||
| await createCheck(pr, 1); | ||
| return; | ||
| } | ||
| if (!check.output || !check.output.text) return; | ||
| let params; | ||
| try { | ||
| params = JSON.parse(check.output.text); | ||
| } catch { | ||
| return; | ||
| } | ||
| // Completed runs only need the rotating sweep to discover reruns. | ||
| // A completed check with no run must keep looking for a late run. | ||
| if (!includeCompleted && check.status === 'completed' && params.run_id) return; | ||
| const fork = { owner: pr.head.repo.owner.login, repo: pr.head.repo.name }; | ||
| const sha = pr.head.sha; | ||
| const run = await findRun(fork, sha, 1); | ||
| if (!run) return; | ||
| const payload = runPayload(fork, sha, run); | ||
| if ( | ||
| payload.status === check.status && | ||
| (payload.conclusion || null) === check.conclusion && | ||
| run.id === params.run_id && | ||
| run.run_attempt === params.run_attempt | ||
| ) { | ||
| return; | ||
| } | ||
| await github.rest.checks.update({ ...upstream, check_run_id: check.id, ...payload }); | ||
| core.info(`#${pr.number}: ${check.status}/${check.conclusion} -> ${payload.status}/${payload.conclusion || ''}`); | ||
| } | ||
| const listing = { ...upstream, state: 'open', sort: 'updated', direction: 'desc' }; | ||
| // The number of open pull requests, from the last-page link of a | ||
| // one-item page: one request, whatever the count. | ||
| async function openPullRequestCount() { | ||
| const response = await github.rest.pulls.list({ ...listing, per_page: 1 }); | ||
| const last = /[?&]page=(\d+)>;\s*rel="last"/.exec(response.headers.link || ''); | ||
| return last ? Number(last[1]) : response.data.length; | ||
| } | ||
| async function update() { | ||
| const hours = Number(process.env.UPDATE_HOURS) || 6; | ||
| const cutoff = Date.now() - hours * 3600 * 1000; | ||
| const seen = new Set(); | ||
| // Select the sweep first so a recent completed PR in this slice | ||
| // is checked once, even though it also appears in the window. | ||
| const slices = Math.max(1, Math.ceil((await openPullRequestCount()) / SWEEP_SIZE)); | ||
| const slot = Math.floor(Date.now() / (15 * 60 * 1000)); | ||
| const page = (slot % slices) + 1; | ||
| const { data: slice } = await github.rest.pulls.list({ ...listing, per_page: SWEEP_SIZE, page }); | ||
| const sweepNumbers = new Set(slice.map((pr) => pr.number)); | ||
| // Pull requests updated within the window, newest first. Stop at | ||
| // the window: GITHUB_TOKEN allows 1,000 requests per hour per | ||
| // repository. | ||
| const pages = github.paginate.iterator(github.rest.pulls.list, { ...listing, per_page: 100 }); | ||
| for await (const page of pages) { | ||
| let stale = false; | ||
| for (const pr of page.data) { | ||
| if (new Date(pr.updated_at).getTime() < cutoff) { | ||
| stale = true; | ||
| break; | ||
| } | ||
| seen.add(pr.number); | ||
| if (isForkPrToMain(pr)) await syncCheck(pr, sweepNumbers.has(pr.number)); | ||
| } | ||
| if (stale) break; | ||
| } | ||
| // Check the rest of the slice, including older pull requests. | ||
| // A fork rerun does not update the upstream pull request. | ||
| for (const pr of slice) { | ||
| if (seen.has(pr.number) || !isForkPrToMain(pr)) continue; | ||
| await syncCheck(pr); | ||
| } | ||
| } | ||
| if (context.eventName === 'pull_request_target') { | ||
| await notify(); | ||
| } else { | ||
| await update(); | ||
| } | ||