From 8adb5f827bd1150d3c9a788ae6b37c1291125f5f Mon Sep 17 00:00:00 2001 From: sdf-jkl Date: Thu, 29 Oct 2026 10:32:49 -0400 Subject: [PATCH 1/2] ci: run external PR builds on contributor forks --- .github/workflows/archery.yml | 15 +- .github/workflows/check_labels.yml | 15 + .github/workflows/cpp.yml | 23 +- .github/workflows/cpp_extra.yml | 65 +++- .github/workflows/dev.yml | 21 +- .github/workflows/docs.yml | 17 +- .github/workflows/docs_light.yml | 31 +- .github/workflows/fork_ci_status.yml | 268 ++++++++++++++ .github/workflows/integration.yml | 15 +- .github/workflows/matlab.yml | 19 +- .github/workflows/package_linux.yml | 13 +- .github/workflows/python.yml | 19 +- .github/workflows/r.yml | 21 +- .github/workflows/r_extra.yml | 13 +- .github/workflows/release_candidate.yml | 16 + .github/workflows/ruby.yml | 21 +- .github/workflows/verify_rc.yml | 11 +- ci/scripts/test_fork_ci_status.cjs | 466 ++++++++++++++++++++++++ docs/source/developers/bug_reports.rst | 32 ++ 19 files changed, 1043 insertions(+), 58 deletions(-) create mode 100644 .github/workflows/fork_ci_status.yml create mode 100644 ci/scripts/test_fork_ci_status.cjs diff --git a/.github/workflows/archery.yml b/.github/workflows/archery.yml index 6ae257e0adad..e8d599dedfb1 100644 --- a/.github/workflows/archery.yml +++ b/.github/workflows/archery.yml @@ -30,7 +30,9 @@ on: - 'dev/archery/**' - 'dev/tasks/**' - 'compose.yaml' + workflow_dispatch: pull_request: + types: [opened, synchronize, reopened, labeled] paths: - '.dockerignore' - '.github/workflows/archery.yml' @@ -43,16 +45,25 @@ env: ARCHERY_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} concurrency: - group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} + # Cancel superseded fork pushes; unrelated labels must not cancel running builds. + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} cancel-in-progress: true permissions: contents: read +# Fork PRs to main run in the contributor's fork; CI: Upstream requests merge-ref validation. jobs: test: - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: >- + !contains(github.event.pull_request.title, 'WIP') && + (github.event.action != 'labeled' || github.event.label.name == 'CI: Upstream') && + (github.event_name != 'pull_request' || + github.repository != 'apache/arrow' || + github.base_ref != 'main' || + github.event.pull_request.head.repo.full_name == github.repository || + contains(github.event.pull_request.labels.*.name, 'CI: Upstream')) name: Archery Unittests and Crossbow Check Config runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/.github/workflows/check_labels.yml b/.github/workflows/check_labels.yml index 86db6efd16fe..2dd47590b26e 100644 --- a/.github/workflows/check_labels.yml +++ b/.github/workflows/check_labels.yml @@ -60,5 +60,20 @@ jobs: echo "LABELS" } >> "${GITHUB_OUTPUT}" ;; + # A push to a contributor fork can pick up labels on its open PR. + # Adding a label alone cannot dispatch into another user's fork; + # the contributor can push again or run that workflow manually. + push) + if [ "${GITHUB_REPOSITORY}" != "apache/arrow" ] && [ "${GITHUB_REF_TYPE}" = "branch" ]; then + { + echo "ci-extra-labels<> "${GITHUB_OUTPUT}" + fi + ;; # Other event types trigger through specific rules in `*_extra.yml` esac diff --git a/.github/workflows/cpp.yml b/.github/workflows/cpp.yml index 21bf615c2ff4..42857d584765 100644 --- a/.github/workflows/cpp.yml +++ b/.github/workflows/cpp.yml @@ -41,7 +41,9 @@ on: - 'compose.yaml' - 'format/Flight.proto' - 'testing' + workflow_dispatch: pull_request: + types: [opened, synchronize, reopened, labeled] paths: - '.dockerignore' - '.github/workflows/cpp.yml' @@ -61,7 +63,8 @@ on: - 'testing' concurrency: - group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} + # Cancel superseded fork pushes; unrelated labels must not cancel running builds. + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} cancel-in-progress: true permissions: @@ -73,11 +76,19 @@ env: ARROW_ENABLE_TIMING_TESTS: OFF DOCKER_VOLUME_PREFIX: ".docker/" +# Fork PRs to main run in the contributor's fork; CI: Upstream requests merge-ref validation. jobs: docker: name: ${{ matrix.title }} runs-on: ${{ matrix.runs-on }} - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: &run-builds >- + !contains(github.event.pull_request.title, 'WIP') && + (github.event.action != 'labeled' || github.event.label.name == 'CI: Upstream') && + (github.event_name != 'pull_request' || + github.repository != 'apache/arrow' || + github.base_ref != 'main' || + github.event.pull_request.head.repo.full_name == github.repository || + contains(github.event.pull_request.labels.*.name, 'CI: Upstream')) timeout-minutes: 75 strategy: fail-fast: false @@ -174,7 +185,7 @@ jobs: build-example: name: C++ Minimal Build Example runs-on: ubuntu-24.04 - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: *run-builds timeout-minutes: 45 steps: - name: Checkout Arrow @@ -195,7 +206,7 @@ jobs: macos: name: ${{ matrix.architecture }} macOS ${{ matrix.macos-version }} C++ runs-on: macos-${{ matrix.macos-version }} - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: *run-builds timeout-minutes: 75 strategy: fail-fast: false @@ -300,7 +311,7 @@ jobs: ci/scripts/cpp_test.sh $(pwd) $(pwd)/build windows: - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: *run-builds name: AMD64 Windows 2022 AVX2 C++ uses: ./.github/workflows/cpp_windows.yml with: @@ -311,7 +322,7 @@ jobs: windows-mingw: name: AMD64 Windows MinGW ${{ matrix.msystem_upper }} C++ runs-on: windows-2022 - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: *run-builds # Build may take 1h+ without cache. timeout-minutes: 120 strategy: diff --git a/.github/workflows/cpp_extra.yml b/.github/workflows/cpp_extra.yml index 1aac6a28d680..52f8941b4e2a 100644 --- a/.github/workflows/cpp_extra.yml +++ b/.github/workflows/cpp_extra.yml @@ -44,6 +44,7 @@ on: - 'testing' tags: - '**' + workflow_dispatch: pull_request: # This trigger ensures that the `check_labels` workflow runs on manual label changes types: @@ -56,7 +57,8 @@ on: 0 0 * * * concurrency: - group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} + # Cancel superseded fork pushes, but keep upstream main commits independent. + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }} cancel-in-progress: true permissions: @@ -84,6 +86,7 @@ jobs: steps: - id: set_enabled if: >- + github.event_name == 'workflow_dispatch' || github.ref_type == 'tag' || (github.event_name == 'schedule' && github.repository == 'apache/arrow') || contains(fromJSON(needs.check-labels.outputs.ci-extra-labels || '[]'), 'CI: Extra') || @@ -92,7 +95,13 @@ jobs: docker: needs: check-enabled - if: needs.check-enabled.outputs.is_enabled == 'true' + if: &run-builds >- + needs.check-enabled.outputs.is_enabled == 'true' && + (github.event_name != 'pull_request' || + github.repository != 'apache/arrow' || + github.base_ref != 'main' || + github.event.pull_request.head.repo.full_name == github.repository || + contains(github.event.pull_request.labels.*.name, 'CI: Upstream')) name: ${{ matrix.title }} runs-on: ${{ matrix.runs-on }} timeout-minutes: 75 @@ -103,16 +112,6 @@ jobs: - image: alpine-linux-cpp runs-on: ubuntu-latest title: AMD64 Alpine Linux - - image: ubuntu-cpp - run-options: >- - -e ARROW_CTEST_TIMEOUT=2000 - -e ARROW_C_FLAGS_DEBUG="-O1" - -e ARROW_CXX_FLAGS_DEBUG="-O1" - -e ARROW_GANDIVA=OFF - -e ARROW_LARGE_MEMORY_TESTS=ON - -e BUILD_WARNING_LEVEL=PRODUCTION - runs-on: "runs-on=${{ github.run_id }}/family=x8i.2xlarge/volume=80gb/spot=capacity-optimized" - title: AMD64 Ubuntu Large Memory Tests - image: ubuntu-cpp-bundled-offline runs-on: ubuntu-latest envs: @@ -138,7 +137,7 @@ jobs: -e ARROW_CXXFLAGS=-march=x86-64 runs-on: ubuntu-latest title: AMD64 Debian SIMD Level NONE - steps: + steps: &docker-steps - name: Checkout Arrow uses: actions/checkout@v7 with: @@ -189,9 +188,34 @@ jobs: continue-on-error: true run: archery docker push ${{ matrix.image }} + large-memory: + # This hardware is supplied by Arrow, not the shared GitHub runner pool. + needs: check-enabled + if: needs.check-enabled.outputs.is_enabled == 'true' && github.repository == 'apache/arrow' + name: ${{ matrix.title }} + runs-on: ${{ matrix.runs-on }} + timeout-minutes: 75 + strategy: + fail-fast: false + matrix: + include: + - image: ubuntu-cpp + continue-on-error: false + envs: [] + run-options: >- + -e ARROW_CTEST_TIMEOUT=2000 + -e ARROW_C_FLAGS_DEBUG="-O1" + -e ARROW_CXX_FLAGS_DEBUG="-O1" + -e ARROW_GANDIVA=OFF + -e ARROW_LARGE_MEMORY_TESTS=ON + -e BUILD_WARNING_LEVEL=PRODUCTION + runs-on: "runs-on=${{ github.run_id }}/family=x8i.2xlarge/volume=80gb/spot=capacity-optimized" + title: AMD64 Ubuntu Large Memory Tests + steps: *docker-steps + msvc-arm64: needs: check-enabled - if: needs.check-enabled.outputs.is_enabled == 'true' + if: *run-builds name: ARM64 Windows 11 MSVC uses: ./.github/workflows/cpp_windows.yml with: @@ -201,7 +225,7 @@ jobs: jni-linux: needs: check-enabled - if: needs.check-enabled.outputs.is_enabled == 'true' + if: *run-builds name: JNI ${{ matrix.platform.runs-on }} ${{ matrix.platform.arch }} runs-on: ${{ matrix.platform.runs-on }} timeout-minutes: 240 @@ -270,7 +294,7 @@ jobs: jni-macos: needs: check-enabled - if: needs.check-enabled.outputs.is_enabled == 'true' + if: *run-builds name: JNI macOS runs-on: macos-14 timeout-minutes: 45 @@ -360,7 +384,7 @@ jobs: jni-windows: needs: check-enabled - if: needs.check-enabled.outputs.is_enabled == 'true' + if: *run-builds name: JNI Windows runs-on: windows-2022 timeout-minutes: 240 @@ -489,7 +513,7 @@ jobs: odbc-linux: needs: check-enabled - if: needs.check-enabled.outputs.is_enabled == 'true' + if: *run-builds name: ODBC Linux runs-on: ubuntu-latest timeout-minutes: 75 @@ -548,7 +572,7 @@ jobs: odbc-macos: needs: check-enabled - if: needs.check-enabled.outputs.is_enabled == 'true' + if: *run-builds name: ODBC ${{ matrix.build-type }} ${{ matrix.architecture }} macOS ${{ matrix.macos-version }} runs-on: macos-${{ matrix.macos-version }} timeout-minutes: 120 @@ -792,7 +816,7 @@ jobs: odbc-msvc: needs: check-enabled - if: needs.check-enabled.outputs.is_enabled == 'true' + if: *run-builds name: ODBC Windows runs-on: windows-2022 timeout-minutes: 240 @@ -978,6 +1002,7 @@ jobs: if: github.event_name == 'schedule' && always() needs: - docker + - large-memory - jni-linux - jni-macos - msvc-arm64 diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 7c3607bd1df3..c1bb7755f55e 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -25,10 +25,13 @@ on: - '!dependabot/**' tags: - '**' + workflow_dispatch: pull_request: + types: [opened, synchronize, reopened, labeled] concurrency: - group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} + # Cancel superseded fork pushes; unrelated labels must not cancel running builds. + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} cancel-in-progress: true permissions: @@ -37,13 +40,21 @@ permissions: env: ARCHERY_DEBUG: 1 +# Fork PRs to main run in the contributor's fork; CI: Upstream requests merge-ref validation. jobs: lint: name: Lint C++, Python, R, Docker, RAT # Use Ubuntu 24.04 to ensure working pre-commit on Ubuntu 24.04. runs-on: ubuntu-24.04 - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: &run-builds >- + !contains(github.event.pull_request.title, 'WIP') && + (github.event.action != 'labeled' || github.event.label.name == 'CI: Upstream') && + (github.event_name != 'pull_request' || + github.repository != 'apache/arrow' || + github.base_ref != 'main' || + github.event.pull_request.head.repo.full_name == github.repository || + contains(github.event.pull_request.labels.*.name, 'CI: Upstream')) timeout-minutes: 15 steps: - name: Checkout Arrow @@ -71,7 +82,7 @@ jobs: release: name: Source Release and Merge Script on ${{ matrix.runs-on }} runs-on: ${{ matrix.runs-on }} - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: *run-builds timeout-minutes: 15 strategy: fail-fast: false @@ -107,6 +118,7 @@ jobs: "cython>=3.1" \ "dev/archery[release]" \ pytest \ + pyyaml \ requests \ scikit-build-core \ setuptools-scm @@ -118,3 +130,6 @@ jobs: - name: Run Merge Script Test shell: bash run: pytest -v dev/test_merge_arrow_pr.py + - name: Test fork CI status and routing + shell: bash + run: node --test ci/scripts/test_fork_ci_status.cjs diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d176095739fb..64383c09408e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -24,12 +24,19 @@ on: - '!dependabot/**' tags: - '**' + workflow_dispatch: pull_request: + types: [opened, synchronize, reopened, labeled] paths: - '.github/workflows/docs.yml' - 'cpp/apidoc/**' - 'docs/**' +concurrency: + # Cancel superseded fork pushes; unrelated labels must not cancel running builds. + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} + cancel-in-progress: true + permissions: contents: read @@ -38,12 +45,20 @@ env: ARROW_ENABLE_TIMING_TESTS: OFF DOCKER_VOLUME_PREFIX: ".docker/" +# Fork PRs to main run in the contributor's fork; CI: Upstream requests merge-ref validation. jobs: complete: name: AMD64 Debian 12 Complete Documentation runs-on: ubuntu-latest - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: >- + !contains(github.event.pull_request.title, 'WIP') && + (github.event.action != 'labeled' || github.event.label.name == 'CI: Upstream') && + (github.event_name != 'pull_request' || + github.repository != 'apache/arrow' || + github.base_ref != 'main' || + github.event.pull_request.head.repo.full_name == github.repository || + contains(github.event.pull_request.labels.*.name, 'CI: Upstream')) timeout-minutes: 150 env: JDK: 17 diff --git a/.github/workflows/docs_light.yml b/.github/workflows/docs_light.yml index 9e62aacfa741..4c32ecbe00bf 100644 --- a/.github/workflows/docs_light.yml +++ b/.github/workflows/docs_light.yml @@ -18,7 +18,22 @@ name: Docs on: + push: + branches: ['**', '!dependabot/**'] + paths: + - '.dockerignore' + - '.github/workflows/docs_light.yml' + - 'ci/docker/conda.dockerfile' + - 'ci/docker/conda-cpp.dockerfile' + - 'ci/docker/conda-python.dockerfile' + - 'ci/scripts/cpp_build.sh' + - 'ci/scripts/python_build.sh' + - 'compose.yaml' + - 'cpp/apidoc/**' + - 'docs/**' + workflow_dispatch: pull_request: + types: [opened, synchronize, reopened, labeled] paths: - '.dockerignore' - '.github/workflows/docs_light.yml' @@ -31,6 +46,11 @@ on: - 'cpp/apidoc/**' - 'docs/**' +concurrency: + # Cancel superseded fork pushes; unrelated labels must not cancel running builds. + group: ${{ github.workflow }}-light-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} + cancel-in-progress: true + permissions: contents: read @@ -39,12 +59,21 @@ env: ARROW_ENABLE_TIMING_TESTS: OFF DOCKER_VOLUME_PREFIX: ".docker/" +# Fork PRs to main run in the contributor's fork; CI: Upstream requests merge-ref validation. jobs: light: name: AMD64 Conda Python 3.12 Sphinx Documentation runs-on: ubuntu-latest - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: >- + !contains(github.event.pull_request.title, 'WIP') && + (github.event.action != 'labeled' || github.event.label.name == 'CI: Upstream') && + (github.event_name != 'pull_request' || + github.repository != 'apache/arrow' || + github.base_ref != 'main' || + github.event.pull_request.head.repo.full_name == github.repository || + contains(github.event.pull_request.labels.*.name, 'CI: Upstream')) && + (github.event_name != 'push' || github.repository != 'apache/arrow') timeout-minutes: 60 env: PYTHON: "3.12" diff --git a/.github/workflows/fork_ci_status.yml b/.github/workflows/fork_ci_status.yml new file mode 100644 index 000000000000..0b4d693190c5 --- /dev/null +++ b/.github/workflows/fork_ci_status.yml @@ -0,0 +1,268 @@ +# 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. + +# Mirror fork results as the informational per-workflow fork checks after upstream PR CI +# completes and through scheduled polling for late runs and reruns. Read fork +# runs and write upstream checks using reviewed default-branch code. +# No checkout, artifacts, or code from the pull request are used here. +# Test with Python 3.11+, PyYAML, and Node.js 24: +# node --test ci/scripts/test_fork_ci_status.cjs +# Tests mock GitHub APIs. End-to-end testing requires a test repository with +# the upstream guards adapted and this workflow installed on its default branch. +name: Fork CI status + +on: + workflow_run: + workflows: ['Archery & Crossbow', 'C++', 'Dev', 'Docs', 'Integration', 'MATLAB', 'Python', 'R', 'C GLib & Ruby', 'RC', 'Verify RC', 'C++ Extra', 'R Extra', 'Package Linux'] + types: [completed] + schedule: + - cron: '7,22,37,52 * * * *' + workflow_dispatch: + +permissions: + actions: read + checks: write + pull-requests: read + +# Like DataFusion, group notifications separately from scheduled/manual sweeps. +# Keep each workflow notification: simultaneous completions initialize different checks. +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name || 'update' }}-${{ github.event.workflow_run.head_branch }}-${{ github.event.workflow_run.workflow_id }} + cancel-in-progress: ${{ github.event_name == 'workflow_run' }} + +jobs: + bridge: + if: github.repository == 'apache/arrow' && (github.event_name != 'workflow_run' || github.event.workflow_run.event == 'pull_request') + 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 + with: + script: | + // Ported from apache/arrow-rs#11788 (originally apache/datafusion#25591). Fork results are informational: + // contributors control their workflows; maintainers can request upstream merge-ref validation with CI: Upstream. + // The filename distinguishes workflows sharing a display name (Docs). + // Extra checks are requested only while the corresponding PR label is set. + const WORKFLOWS = { + 'archery.yml': { name: 'Archery & Crossbow' }, + 'cpp.yml': { name: 'C++' }, + 'dev.yml': { name: 'Dev' }, + 'docs.yml': { name: 'Docs' }, + 'docs_light.yml': { name: 'Docs Light' }, + 'integration.yml': { name: 'Integration' }, + 'matlab.yml': { name: 'MATLAB' }, + 'python.yml': { name: 'Python' }, + 'r.yml': { name: 'R' }, + 'ruby.yml': { name: 'C GLib & Ruby' }, + 'release_candidate.yml': { name: 'RC' }, + 'verify_rc.yml': { name: 'Verify RC' }, + 'cpp_extra.yml': { name: 'C++ Extra', label: 'CI: Extra: C++' }, + 'r_extra.yml': { name: 'R Extra', label: 'CI: Extra: R' }, + 'package_linux.yml': { name: 'Package Linux', label: 'CI: Extra: Package: Linux' }, + }; + const upstream = { owner: context.repo.owner, repo: context.repo.repo }; + const repository = upstream.owner + '/' + upstream.repo; + const GUIDE = 'https://github.com/apache/arrow/blob/main/docs/source/developers/bug_reports.rst#ci-on-contributor-forks'; + const SWEEP_SIZE = 5; + const listing = { ...upstream, state: 'open', base: 'main', sort: 'updated', direction: 'desc' }; + const isForkPr = (pr) => pr.state === 'open' && pr.head.repo && + pr.head.repo.full_name.toLowerCase() !== repository.toLowerCase() && pr.base.ref === 'main'; + const externalId = (pr, workflow) => 'arrow-fork-ci:' + pr.number + ':' + workflow; + const conclusions = new Set([ + 'action_required', 'cancelled', 'failure', 'neutral', 'skipped', 'success', 'timed_out', + ]); + const UPDATE_NOTE = + 'This check is informational: the contributor controls the fork workflow. ' + + 'Arrow has no merge queue. Maintainers can apply CI: Upstream to test the PR merge ref upstream. ' + + 'Active or missing runs on recently updated PRs are polled every 15 minutes; ' + + 'a rotating sweep also discovers reruns on older PRs. Scheduled updates may be delayed.'; + + // An absent workflow/run is expected on a fork with Actions disabled. Other + // API errors must fail the bridge rather than falsely reporting missing CI. + async function optional(request) { + try { + return (await request()).data; + } catch (error) { + if (error.status === 404) return null; + throw error; + } + } + + async function syncCheck(pr, includeCompleted = true, initializeWorkflow = null) { + if (!isForkPr(pr)) return; + const fork = { owner: pr.head.repo.owner.login, repo: pr.head.repo.name }; + const sha = pr.head.sha; + const checks = await github.paginate(github.rest.checks.listForRef, { + ...upstream, ref: sha, per_page: 100, + }); + // Share the fork run/workflow listings across all checks for this head. + let runs; + let workflows; + for (const [WORKFLOW, config] of Object.entries(WORKFLOWS)) { + if (initializeWorkflow && WORKFLOW !== initializeWorkflow) continue; + if (config.label && !pr.labels?.some(label => + label.name === 'CI: Extra' || label.name === config.label)) continue; + const CHECK = config.name + ' (fork)'; + const check = checks.find((item) => + item.app?.slug === 'github-actions' && item.external_id === externalId(pr, WORKFLOW)); + // Path-filtered workflows initialize checks only on completion events. + // Sweeps must not request unrelated builds on every open PR. + if (!check && initializeWorkflow !== WORKFLOW) continue; + let previous = {}; + try { previous = JSON.parse(check?.output?.text || '{}'); } catch {} + // A missing run must keep being polled, even though its check is completed. + if (!includeCompleted && check?.status === 'completed' && previous.run_id) continue; + + if (!runs) { + runs = await optional(async () => ({ data: await github.paginate( + github.rest.actions.listWorkflowRunsForRepo, + { ...fork, head_sha: sha, branch: pr.head.ref, per_page: 100 }, + ) })) || []; + } + // Ignore PR merge-ref runs and other workflow files. Fork code is + // contributor-controlled; the check is feedback, not a trust boundary. + const run = runs.filter((item) => + item.path === '.github/workflows/' + WORKFLOW && + item.head_sha === sha && item.head_branch === pr.head.ref && + ['push', 'workflow_dispatch'].includes(item.event) + ).sort((a, b) => b.id - a.id)[0]; + let payload; + if (run) { + const status = run.status === 'completed' ? 'completed' : + run.status === 'in_progress' ? 'in_progress' : 'queued'; + payload = { + status, + ...(status === 'completed' ? { + conclusion: conclusions.has(run.conclusion) ? run.conclusion : 'failure', + } : {}), + details_url: run.html_url, + output: { + title: 'Fork CI: ' + (status === 'completed' ? run.conclusion : status), + summary: [ + '[CI run on ' + pr.head.repo.full_name + '](' + run.html_url + ') for ' + sha + '.', + 'Rerun failed checks in the fork. A green fork run tests the branch head, not its merge with main.', + UPDATE_NOTE, + ].join('\n\n'), + text: JSON.stringify({ head_sha: sha, run_id: run.id, run_attempt: run.run_attempt }), + }, + }; + } else { + if (!workflows) { + workflows = await optional(async () => ({ data: await github.paginate( + github.rest.actions.listRepoWorkflows, { ...fork, per_page: 100 }, + ) })) || []; + } + const workflow = workflows.find(item => item.path === '.github/workflows/' + WORKFLOW); + const disabled = !workflow || workflow.state.startsWith('disabled'); + payload = { + status: 'completed', + conclusion: disabled ? 'action_required' : 'neutral', + details_url: 'https://github.com/' + pr.head.repo.full_name + '/actions', + output: { + title: disabled ? 'Enable CI on your fork' : 'No fork CI run for this commit', + summary: [ + 'No CI run was found for ' + sha + ' on ' + pr.head.repo.full_name + '.', + 'Enable workflows in the fork’s Actions tab, sync/rebase to include the fork CI workflow, ' + + 'then push your branch or manually run ' + WORKFLOW + ' on that branch. ', + 'Path filters may skip a push; Extra workflows need a push after labelling or a manual run. Verify RC also requires an rc_tag input.', + UPDATE_NOTE, + '[Fork CI setup](' + GUIDE + ')', + ].join('\n\n'), + text: JSON.stringify({ head_sha: sha, run_id: null }), + }, + }; + } + if (check && check.status === payload.status && + check.conclusion === (payload.conclusion || null) && + check.output?.text === payload.output.text && + check.output?.summary === payload.output.summary) continue; + if (check) { + await github.rest.checks.update({ ...upstream, check_run_id: check.id, ...payload }); + } else { + await github.rest.checks.create({ + ...upstream, name: CHECK, head_sha: sha, external_id: externalId(pr, WORKFLOW), ...payload, + }); + } + core.info('#' + pr.number + ' ' + WORKFLOW + ': ' + (payload.conclusion || payload.status)); + } + } + + async function notify() { + const run = context.payload.workflow_run; + const workflow = run.path?.split('/').pop().split('@')[0]; + if (!Object.hasOwn(WORKFLOWS, workflow)) return; + if (run.event !== 'pull_request' || !run.head_repository || + run.head_repository.full_name.toLowerCase() === repository.toLowerCase()) return; + // GitHub can leave workflow_run.pull_requests empty for fork PRs. Resolve + // from the API instead, and recheck the current head to ignore stale runs. + const prs = await github.paginate(github.rest.pulls.list, { + ...listing, head: run.head_repository.owner.login + ':' + run.head_branch, per_page: 100, + }); + for (const pr of prs) { + if (pr.head.sha === run.head_sha && + pr.head.repo?.full_name.toLowerCase() === run.head_repository.full_name.toLowerCase()) { + await syncCheck(pr, true, workflow); + } + } + } + + async function update() { + const errors = []; + // A failed update must not prevent unrelated PRs from being refreshed. + async function syncPr(pr, includeCompleted) { + try { + await syncCheck(pr, includeCompleted); + } catch (error) { + errors.push('#' + pr.number + ': ' + error.message); + } + } + const count = await github.rest.pulls.list({ ...listing, per_page: 1 }); + const last = /[?&]page=(\d+)>;\s*rel="last"/.exec(count.headers.link || ''); + const total = last ? Number(last[1]) : count.data.length; + if (!total) return; + const slices = Math.ceil(total / SWEEP_SIZE); + const slot = Math.floor(Date.now() / (15 * 60 * 1000)); + const { data: slice } = await github.rest.pulls.list({ + ...listing, per_page: SWEEP_SIZE, page: (slot % slices) + 1, + }); + const sweepNumbers = new Set(slice.map((pr) => pr.number)); + const seen = new Set(); + const cutoff = Date.now() - 6 * 3600 * 1000; + // Poll active and missing checks on recently updated PRs, and completed + // checks only in the rotating slice. This bounds expensive fork API calls. + 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); + await syncPr(pr, sweepNumbers.has(pr.number)); + } + if (stale) break; + } + for (const pr of slice) { + if (!seen.has(pr.number)) await syncPr(pr); + } + if (errors.length) core.setFailed(errors.join('\n')); + } + + if (repository === 'apache/arrow') { + if (context.eventName === 'workflow_run') await notify(); + else await update(); + } diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 197eb19b1510..13f00ba64ab2 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -34,7 +34,9 @@ on: - 'cpp/**' - 'format/**' - 'ruby/red-arrow-format/**' + workflow_dispatch: pull_request: + types: [opened, synchronize, reopened, labeled] paths: - '.dockerignore' - '.github/workflows/integration.yml' @@ -47,7 +49,8 @@ on: - 'ruby/red-arrow-format/**' concurrency: - group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} + # Cancel superseded fork pushes; unrelated labels must not cancel running builds. + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} cancel-in-progress: true permissions: @@ -57,12 +60,20 @@ env: ARCHERY_DEBUG: 1 DOCKER_VOLUME_PREFIX: ".docker/" +# Fork PRs to main run in the contributor's fork; CI: Upstream requests merge-ref validation. jobs: docker: name: AMD64 Conda Integration Test runs-on: ubuntu-latest - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: >- + !contains(github.event.pull_request.title, 'WIP') && + (github.event.action != 'labeled' || github.event.label.name == 'CI: Upstream') && + (github.event_name != 'pull_request' || + github.repository != 'apache/arrow' || + github.base_ref != 'main' || + github.event.pull_request.head.repo.full_name == github.repository || + contains(github.event.pull_request.labels.*.name, 'CI: Upstream')) timeout-minutes: 60 steps: - name: Checkout Arrow diff --git a/.github/workflows/matlab.yml b/.github/workflows/matlab.yml index da36b83bbec9..a63b84ed777d 100644 --- a/.github/workflows/matlab.yml +++ b/.github/workflows/matlab.yml @@ -29,7 +29,9 @@ on: - 'ci/scripts/matlab*.sh' - 'matlab/**' - 'cpp/src/arrow/**' + workflow_dispatch: pull_request: + types: [opened, synchronize, reopened, labeled] paths: - '.github/workflows/matlab.yml' - 'ci/scripts/matlab*.sh' @@ -37,18 +39,27 @@ on: - 'cpp/src/arrow/**' concurrency: - group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} + # Cancel superseded fork pushes; unrelated labels must not cancel running builds. + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} cancel-in-progress: true permissions: contents: read +# Fork PRs to main run in the contributor's fork; CI: Upstream requests merge-ref validation. jobs: ubuntu: name: AMD64 Ubuntu 22.04 MATLAB runs-on: ubuntu-22.04 - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: &run-builds >- + !contains(github.event.pull_request.title, 'WIP') && + (github.event.action != 'labeled' || github.event.label.name == 'CI: Upstream') && + (github.event_name != 'pull_request' || + github.repository != 'apache/arrow' || + github.base_ref != 'main' || + github.event.pull_request.head.repo.full_name == github.repository || + contains(github.event.pull_request.labels.*.name, 'CI: Upstream')) steps: - name: Check out repository uses: actions/checkout@v7 @@ -90,7 +101,7 @@ jobs: macos: name: ${{ matrix.architecture }} macOS ${{ matrix.macos-version }} MATLAB runs-on: macos-${{ matrix.macos-version }} - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: *run-builds strategy: matrix: include: @@ -139,7 +150,7 @@ jobs: windows: name: AMD64 Windows 2022 MATLAB runs-on: windows-2022 - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: *run-builds steps: - name: Check out repository uses: actions/checkout@v7 diff --git a/.github/workflows/package_linux.yml b/.github/workflows/package_linux.yml index b1c93fd16792..6d7cfd2a81cd 100644 --- a/.github/workflows/package_linux.yml +++ b/.github/workflows/package_linux.yml @@ -37,6 +37,7 @@ on: - 'format/Flight.proto' tags: - "apache-arrow-*-rc*" + workflow_dispatch: pull_request: # This trigger ensures that the `check_labels` workflow runs on manual label changes types: @@ -48,7 +49,8 @@ on: - cron: "0 2 * * *" concurrency: - group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} + # Cancel superseded fork pushes, but keep upstream main commits independent. + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }} cancel-in-progress: true permissions: @@ -71,6 +73,7 @@ jobs: steps: - id: set_enabled if: >- + github.event_name == 'workflow_dispatch' || github.ref_type == 'tag' || (github.event_name == 'schedule' && github.repository == 'apache/arrow') || contains(fromJSON(needs.check-labels.outputs.ci-extra-labels || '[]'), 'CI: Extra') || @@ -79,7 +82,13 @@ jobs: package: needs: check-enabled - if: needs.check-enabled.outputs.is_enabled == 'true' + if: >- + needs.check-enabled.outputs.is_enabled == 'true' && + (github.event_name != 'pull_request' || + github.repository != 'apache/arrow' || + github.base_ref != 'main' || + github.event.pull_request.head.repo.full_name == github.repository || + contains(github.event.pull_request.labels.*.name, 'CI: Upstream')) permissions: # Upload artifacts to GitHub Release contents: write diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index bf7285182a3b..c4cd0ac81b5a 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -32,7 +32,9 @@ on: - 'compose.yaml' - 'docs/source/python/**' - 'python/**' + workflow_dispatch: pull_request: + types: [opened, synchronize, reopened, labeled] paths: - '.dockerignore' - '.github/workflows/python.yml' @@ -43,7 +45,8 @@ on: - 'python/**' concurrency: - group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} + # Cancel superseded fork pushes; unrelated labels must not cancel running builds. + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} cancel-in-progress: true permissions: @@ -53,12 +56,20 @@ env: ARCHERY_DEBUG: 1 DOCKER_VOLUME_PREFIX: ".docker/" +# Fork PRs to main run in the contributor's fork; CI: Upstream requests merge-ref validation. jobs: docker: name: ${{ matrix.title }} runs-on: ubuntu-latest - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: &run-builds >- + !contains(github.event.pull_request.title, 'WIP') && + (github.event.action != 'labeled' || github.event.label.name == 'CI: Upstream') && + (github.event_name != 'pull_request' || + github.repository != 'apache/arrow' || + github.base_ref != 'main' || + github.event.pull_request.head.repo.full_name == github.repository || + contains(github.event.pull_request.labels.*.name, 'CI: Upstream')) timeout-minutes: 60 strategy: fail-fast: false @@ -147,7 +158,7 @@ jobs: macos: name: ${{ matrix.architecture }} macOS ${{ matrix.macos-version }} Python 3 runs-on: macos-${{ matrix.macos-version }} - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: *run-builds timeout-minutes: 60 strategy: fail-fast: false @@ -254,7 +265,7 @@ jobs: windows: name: AMD64 Windows 2022 Python 3.13 runs-on: windows-2022 - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: *run-builds timeout-minutes: 60 env: CCACHE_DIR: C:/ccache diff --git a/.github/workflows/r.yml b/.github/workflows/r.yml index 98b301d095dc..19c2c2c6e963 100644 --- a/.github/workflows/r.yml +++ b/.github/workflows/r.yml @@ -36,7 +36,9 @@ on: - "cpp/**" - "compose.yaml" - "r/**" + workflow_dispatch: pull_request: + types: [opened, synchronize, reopened, labeled] paths: - '.dockerignore' - ".github/workflows/r.yml" @@ -51,7 +53,8 @@ on: - "r/**" concurrency: - group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} + # Cancel superseded fork pushes; unrelated labels must not cancel running builds. + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} cancel-in-progress: true permissions: @@ -61,11 +64,19 @@ env: ARCHERY_DEBUG: 1 DOCKER_VOLUME_PREFIX: ".docker/" +# Fork PRs to main run in the contributor's fork; CI: Upstream requests merge-ref validation. jobs: ubuntu: name: AMD64 Ubuntu ${{ matrix.ubuntu }} R ${{ matrix.r }} Force-Tests ${{ matrix.force-tests }} runs-on: ubuntu-latest - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: &run-builds >- + !contains(github.event.pull_request.title, 'WIP') && + (github.event.action != 'labeled' || github.event.label.name == 'CI: Upstream') && + (github.event_name != 'pull_request' || + github.repository != 'apache/arrow' || + github.base_ref != 'main' || + github.event.pull_request.head.repo.full_name == github.repository || + contains(github.event.pull_request.labels.*.name, 'CI: Upstream')) timeout-minutes: 75 strategy: fail-fast: false @@ -138,7 +149,7 @@ jobs: bundled: name: "${{ matrix.config.org }}/${{ matrix.config.image }}:${{ matrix.config.tag }}" runs-on: ubuntu-latest - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: *run-builds timeout-minutes: 60 strategy: fail-fast: false @@ -199,7 +210,7 @@ jobs: windows-cpp: name: AMD64 Windows C++ RTools ${{ matrix.config.rtools }} ${{ matrix.config.arch }} runs-on: windows-2022 - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: *run-builds timeout-minutes: 90 strategy: fail-fast: false @@ -253,7 +264,7 @@ jobs: needs: [windows-cpp] name: AMD64 Windows R ${{ matrix.config.rversion }} runs-on: windows-2022 - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: *run-builds timeout-minutes: 75 strategy: fail-fast: false diff --git a/.github/workflows/r_extra.yml b/.github/workflows/r_extra.yml index 4abcc5383698..ca15b03f9011 100644 --- a/.github/workflows/r_extra.yml +++ b/.github/workflows/r_extra.yml @@ -39,6 +39,7 @@ on: - 'r/**' tags: - '**' + workflow_dispatch: pull_request: # This trigger ensures that the `check_labels` workflow runs on manual label changes types: @@ -51,7 +52,8 @@ on: 0 4 * * * concurrency: - group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} + # Cancel superseded fork pushes, but keep upstream main commits independent. + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }} cancel-in-progress: true permissions: @@ -74,6 +76,7 @@ jobs: steps: - id: set_enabled if: >- + github.event_name == 'workflow_dispatch' || github.ref_type == 'tag' || (github.event_name == 'schedule' && github.repository == 'apache/arrow') || contains(fromJSON(needs.check-labels.outputs.ci-extra-labels || '[]'), 'CI: Extra') || @@ -82,7 +85,13 @@ jobs: docker: needs: check-enabled - if: needs.check-enabled.outputs.is_enabled == 'true' + if: >- + needs.check-enabled.outputs.is_enabled == 'true' && + (github.event_name != 'pull_request' || + github.repository != 'apache/arrow' || + github.base_ref != 'main' || + github.event.pull_request.head.repo.full_name == github.repository || + contains(github.event.pull_request.labels.*.name, 'CI: Upstream')) name: ${{ matrix.title }} runs-on: ${{ matrix.runs-on }} timeout-minutes: 75 diff --git a/.github/workflows/release_candidate.yml b/.github/workflows/release_candidate.yml index f34d7fadb029..04c4fd7c7470 100644 --- a/.github/workflows/release_candidate.yml +++ b/.github/workflows/release_candidate.yml @@ -28,17 +28,33 @@ on: - ".github/workflows/release_candidate.yml" - "dev/release/utils-create-release-tarball.sh" - "dev/release/utils-generate-checksum.sh" + workflow_dispatch: pull_request: + types: [opened, synchronize, reopened, labeled] paths: - ".github/workflows/release_candidate.yml" - "dev/release/utils-create-release-tarball.sh" - "dev/release/utils-generate-checksum.sh" +concurrency: + # Cancel superseded fork pushes; unrelated labels must not cancel running builds. + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} + cancel-in-progress: true + permissions: contents: write +# Fork PRs to main run in the contributor's fork; CI: Upstream requests merge-ref validation. jobs: publish: + if: >- + !contains(github.event.pull_request.title, 'WIP') && + (github.event.action != 'labeled' || github.event.label.name == 'CI: Upstream') && + (github.event_name != 'pull_request' || + github.repository != 'apache/arrow' || + github.base_ref != 'main' || + github.event.pull_request.head.repo.full_name == github.repository || + contains(github.event.pull_request.labels.*.name, 'CI: Upstream')) name: Publish runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 0d4655a24115..8c94614d2cc0 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -38,7 +38,9 @@ on: - 'cpp/**' - 'compose.yaml' - 'ruby/**' + workflow_dispatch: pull_request: + types: [opened, synchronize, reopened, labeled] paths: - '.dockerignore' - '.github/workflows/ruby.yml' @@ -55,7 +57,8 @@ on: - 'ruby/**' concurrency: - group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} + # Cancel superseded fork pushes; unrelated labels must not cancel running builds. + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} cancel-in-progress: true permissions: @@ -66,12 +69,20 @@ env: ARCHERY_DEBUG: 1 DOCKER_VOLUME_PREFIX: ".docker/" +# Fork PRs to main run in the contributor's fork; CI: Upstream requests merge-ref validation. jobs: ubuntu: name: AMD64 Ubuntu ${{ matrix.ubuntu }} GLib & Ruby runs-on: ubuntu-latest - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: &run-builds >- + !contains(github.event.pull_request.title, 'WIP') && + (github.event.action != 'labeled' || github.event.label.name == 'CI: Upstream') && + (github.event_name != 'pull_request' || + github.repository != 'apache/arrow' || + github.base_ref != 'main' || + github.event.pull_request.head.repo.full_name == github.repository || + contains(github.event.pull_request.labels.*.name, 'CI: Upstream')) timeout-minutes: 60 strategy: fail-fast: false @@ -136,7 +147,7 @@ jobs: macos: name: ARM64 macOS GLib & Ruby runs-on: macos-latest - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: *run-builds timeout-minutes: 60 env: ARROW_BUILD_STATIC: OFF @@ -219,7 +230,7 @@ jobs: windows-mingw: name: AMD64 Windows MinGW ${{ matrix.mingw-n-bits }} GLib & Ruby runs-on: windows-2022 - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: *run-builds timeout-minutes: 90 strategy: fail-fast: false @@ -343,7 +354,7 @@ jobs: windows-msvc: name: AMD64 Windows MSVC GLib runs-on: windows-2022 - if: ${{ !contains(github.event.pull_request.title, 'WIP') }} + if: *run-builds timeout-minutes: 240 strategy: fail-fast: false diff --git a/.github/workflows/verify_rc.yml b/.github/workflows/verify_rc.yml index 4479a02c4f6d..9f8bd1790641 100644 --- a/.github/workflows/verify_rc.yml +++ b/.github/workflows/verify_rc.yml @@ -22,6 +22,7 @@ on: tags: - "*-rc*" pull_request: + types: [opened, synchronize, reopened, labeled] paths: - ".github/workflows/verify_rc.yml" workflow_dispatch: @@ -41,6 +42,14 @@ env: jobs: target: + if: >- + !contains(github.event.pull_request.title, 'WIP') && + (github.event.action != 'labeled' || github.event.label.name == 'CI: Upstream') && + (github.event_name != 'pull_request' || + github.repository != 'apache/arrow' || + github.base_ref != 'main' || + github.event.pull_request.head.repo.full_name == github.repository || + contains(github.event.pull_request.labels.*.name, 'CI: Upstream')) name: Target runs-on: ubuntu-latest timeout-minutes: 5 @@ -62,7 +71,7 @@ jobs: tag="$(gh release list \ --jq '.[] | select(.isPrerelease) | .tagName' \ --json tagName,isPrerelease \ - --repo ${GITHUB_REPOSITORY} | \ + --repo apache/arrow | \ head -n1)" ;; *) diff --git a/ci/scripts/test_fork_ci_status.cjs b/ci/scripts/test_fork_ci_status.cjs new file mode 100644 index 000000000000..9f1ec51f3f20 --- /dev/null +++ b/ci/scripts/test_fork_ci_status.cjs @@ -0,0 +1,466 @@ +// 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. + +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); + +function workflow(name) { + return JSON.parse(execFileSync('python3', [ + '-c', 'import json, sys, yaml; print(json.dumps(yaml.safe_load(sys.stdin)))', + ], { + input: readFileSync(path.join(__dirname, '../../.github/workflows', name)), + encoding: 'utf8', + })); +} +const bridgeWorkflow = workflow('fork_ci_status.yml'); +const ciWorkflow = workflow('cpp.yml'); +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; +const bridge = new AsyncFunction('github', 'context', 'core', 'Date', + bridgeWorkflow.jobs.bridge.steps[0].with.script); +const now = Date.UTC(2026, 9, 20, 12); +class Clock extends Date { static now() { return now; } } +const sha = 'a'.repeat(40); +const pr = { + number: 10, state: 'open', updated_at: new Date(now).toISOString(), + head: { + sha, ref: 'feature', + repo: { owner: { login: 'contributor' }, name: 'arrow', full_name: 'contributor/arrow' }, + }, + base: { ref: 'main' }, +}; +const run = (id = 1, extra = {}) => ({ + id, path: '.github/workflows/cpp.yml', event: 'push', head_sha: sha, head_branch: 'feature', run_attempt: 1, + status: 'completed', conclusion: 'success', + html_url: 'https://github.com/contributor/arrow/actions/runs/' + id, + ...extra, +}); + +// A path-filtered C++ completion initialized this check on a previous event. +const seed = { + id: 100, app: { slug: 'github-actions' }, external_id: 'arrow-fork-ci:10:cpp.yml', + status: 'queued', output: {}, +}; + +async function execute({ + event = 'schedule', checks = [seed], runs = [run()], workflowState = 'active', + recent = [pr], slice = [pr], candidates = [pr], repo = 'arrow', + owner = 'apache', runError, count = 1, notification = {}, checkErrors = {}, +} = {}) { + const writes = [], calls = [], failures = []; + let sweepPage; + const github = { rest: { checks: {}, actions: {}, pulls: {} } }; + github.rest.checks.listForRef = async () => {}; + github.rest.pulls.list = async (params) => { + calls.push(['pulls', params]); + if (params.per_page === 1) return { + data: count ? [pr] : [], + headers: count ? { link: '; rel="last"' } : {}, + }; + assert.equal(params.per_page, 5); + sweepPage = params.page; + return { data: slice }; + }; + github.rest.actions.listWorkflowRunsForRepo = async () => {}; + github.rest.actions.listRepoWorkflows = async () => {}; + github.rest.checks.create = async (payload) => writes.push({ method: 'create', ...payload }); + github.rest.checks.update = async (payload) => writes.push({ method: 'update', ...payload }); + github.paginate = async (fn, params) => { + if (fn === github.rest.actions.listWorkflowRunsForRepo) { + calls.push(['runs', params]); + assert.equal(params.owner, 'contributor'); + assert.equal(params.head_sha, sha); + assert.equal(params.branch, 'feature'); + if (runError) throw Object.assign(new Error('API failure'), { status: runError }); + return runs; + } + if (fn === github.rest.actions.listRepoWorkflows) { + calls.push(['workflows', params]); + return workflowState ? [{ path: '.github/workflows/cpp.yml', state: workflowState }] : []; + } + if (fn === github.rest.checks.listForRef) { + calls.push(['checks', params]); + if (checkErrors[params.ref]) throw new Error(checkErrors[params.ref]); + return checks; + } + assert.equal(fn, github.rest.pulls.list); + assert.equal(params.head, 'contributor:feature'); + return candidates; + }; + github.paginate.iterator = async function* () { + for (let i = 0; i < recent.length; i += 100) yield { data: recent.slice(i, i + 100) }; + }; + await bridge(github, { + repo: { owner, repo }, eventName: event, + payload: { workflow_run: { + path: '.github/workflows/cpp.yml', event: 'pull_request', head_sha: sha, head_branch: 'feature', + head_repository: pr.head.repo, pull_requests: [], ...notification, + } }, + }, { info: () => {}, setFailed: (message) => failures.push(message) }, Clock); + return { writes, calls, sweepPage, failures }; +} +async function existing(runValue = run()) { + const { writes } = await execute({ runs: [runValue] }); + return { + id: 100, app: { slug: 'github-actions' }, external_id: 'arrow-fork-ci:10:cpp.yml', + ...writes[0], conclusion: writes[0].conclusion || null, + }; +} + +test('CI completion discovers a fork PR even when the event PR list is empty', async () => { + const { writes } = await execute({ event: 'workflow_run', checks: [] }); + assert.equal(writes.length, 1); + assert.equal(writes[0].head_sha, sha); + assert.equal(writes[0].name, 'C++ (fork)'); + assert.equal(writes[0].conclusion, 'success'); + assert.equal(writes[0].details_url, run().html_url); +}); +test('notifications ignore stale branch heads, release PRs, closed PRs and deleted forks', async () => { + for (const candidate of [ + { ...pr, head: { ...pr.head, sha: 'b'.repeat(40) } }, + { ...pr, base: { ref: 'maintenance' } }, + { ...pr, state: 'closed' }, + { ...pr, head: { ...pr.head, repo: null } }, + ]) { + assert.deepEqual((await execute({ event: 'workflow_run', candidates: [candidate] })).writes, []); + } +}); +test('upstream pushes and merge-queue completions cannot create fork checks', async () => { + for (const event of ['push', 'merge_group']) { + assert.deepEqual((await execute({ event: 'workflow_run', notification: { event } })).writes, []); + } +}); +test('the bridge does nothing when copied into a fork', async () => { + const result = await execute({ owner: 'contributor' }); + assert.deepEqual(result.calls, []); + assert.deepEqual(result.writes, []); +}); +test('only runs for the exact branch head on push/manual events are mirrored', async () => { + const result = await execute({ runs: [ + run(8, { head_sha: 'b'.repeat(40) }), + run(7, { head_branch: 'other' }), + run(6, { event: 'pull_request' }), + run(5, { event: 'workflow_dispatch' }), + run(1), + ] }); + assert.equal(result.writes[0].details_url, run(5).html_url); +}); +for (const [state, expected] of [ + ['active', 'neutral'], ['disabled_fork', 'action_required'], [null, 'action_required'], +]) { + test('missing run reports workflow state ' + state, async () => { + const { writes } = await execute({ runs: [], workflowState: state }); + assert.equal(writes[0].conclusion, expected); + assert.match(writes[0].output.summary, /Enable workflows/); + assert.equal(JSON.parse(writes[0].output.text).run_id, null); + }); +} +test('a missing workflow endpoint is handled as missing CI', async () => { + const { writes } = await execute({ runError: 404, workflowState: null }); + assert.equal(writes[0].conclusion, 'action_required'); +}); +test('permission, rate limit and server errors are not reported as missing CI', async () => { + for (const runError of [403, 429, 500]) { + const result = await execute({ runError }); + assert.deepEqual(result.writes, []); + assert.deepEqual(result.failures, ['#10: API failure']); + await assert.rejects(execute({ event: 'workflow_run', runError }), /API failure/); + } +}); +for (const conclusion of ['failure', 'cancelled', 'timed_out', 'startup_failure', 'stale']) { + test('mirrors ' + conclusion + ' without reporting success', async () => { + const { writes } = await execute({ runs: [run(1, { conclusion })] }); + assert.equal(writes[0].conclusion, + ['startup_failure', 'stale'].includes(conclusion) ? 'failure' : conclusion); + }); +} +test('a stale run updates an existing check with failure, not the reserved conclusion', async () => { + const { writes } = await execute({ + checks: [await existing()], runs: [run(1, { conclusion: 'stale' })], + }); + assert.equal(writes[0].method, 'update'); + assert.equal(writes[0].conclusion, 'failure'); +}); +for (const source of ['recent', 'slice']) { + test('the sweep continues after errors in the ' + source + ' PRs and reports every failure', async () => { + const broken = ['b', 'c'].map((letter, index) => ({ + ...pr, number: 20 + index, head: { ...pr.head, sha: letter.repeat(40) }, + })); + const result = await execute({ + recent: [], slice: [], [source]: [...broken, pr], count: 3, + checkErrors: { + [broken[0].head.sha]: 'First API failure', + [broken[1].head.sha]: 'Second API failure', + }, + }); + assert.equal(result.writes.length, 1); + assert.equal(result.writes[0].check_run_id, seed.id); + assert.equal(result.writes[0].conclusion, 'success'); + assert.deepEqual(result.failures, ['#20: First API failure\n#21: Second API failure']); + }); +} +test('a rerun of the same workflow run updates the existing check', async () => { + const check = await existing(run(1, { conclusion: 'failure' })); + const { writes } = await execute({ checks: [check], runs: [run(1, { run_attempt: 2 })] }); + assert.equal(writes[0].method, 'update'); + assert.equal(writes[0].check_run_id, 100); + assert.equal(writes[0].conclusion, 'success'); + assert.equal(JSON.parse(writes[0].output.text).run_attempt, 2); +}); +test('a manual run can replace a failed push run', async () => { + const check = await existing(run(1, { conclusion: 'failure' })); + const { writes } = await execute({ + checks: [check], runs: [run(2, { event: 'workflow_dispatch' }), run(1)], + }); + assert.equal(writes[0].details_url, run(2).html_url); +}); +test('a deleted run clears its stale success result', async () => { + const { writes } = await execute({ checks: [await existing()], runs: [] }); + assert.equal(writes[0].method, 'update'); + assert.equal(writes[0].conclusion, 'neutral'); +}); +test('unchanged results do not consume check write requests', async () => { + const check = await existing(); + // GitHub can replace details_url with the check's own URL. + for (const details_url of [check.details_url, 'https://github.com/apache/arrow/runs/100']) { + assert.deepEqual((await execute({ checks: [{ ...check, details_url }] })).writes, []); + } +}); +test('a same-name check from another app or PR is not overwritten', async () => { + const check = await existing(); + for (const foreign of [ + { ...check, app: { slug: 'other-app' } }, + { ...check, external_id: 'arrow-fork-ci:20:cpp.yml' }, + ]) { + assert.equal((await execute({ event: 'workflow_run', checks: [foreign] })).writes[0].method, 'create'); + } +}); +test('recent completed checks wait for the rotating sweep', async () => { + const { writes, calls } = await execute({ checks: [await existing()], slice: [] }); + assert.deepEqual(writes, []); + assert.equal(calls.filter(([kind]) => kind === 'runs').length, 0); +}); +test('late runs are recovered outside the sweep slice', async () => { + const missing = await execute({ runs: [] }); + const check = { ...await existing(), ...missing.writes[0] }; + const { writes } = await execute({ checks: [check], slice: [] }); + assert.equal(writes[0].conclusion, 'success'); +}); +for (const status of ['queued', 'in_progress']) { + test('polls recent ' + status + ' runs outside the sweep slice', async () => { + const check = await existing(run(1, { status, conclusion: null })); + const { writes } = await execute({ checks: [check], slice: [] }); + assert.equal(writes[0].conclusion, 'success'); + }); +} +test('the sweep reaches old PRs beyond 500 open PRs', async () => { + const old = { ...pr, updated_at: new Date(now - 24 * 3600 * 1000).toISOString() }; + const { writes, sweepPage } = await execute({ count: 600, recent: [old], slice: [old] }); + assert.equal(sweepPage, Math.floor(now / (15 * 60 * 1000)) % 120 + 1); + assert.equal(writes.length, 1); +}); +test('an active PR appearing in both lists is polled once', async () => { + const { calls } = await execute(); + assert.equal(calls.filter(([kind]) => kind === 'runs').length, 1); +}); +test('no open PRs means no fork API calls', async () => { + const result = await execute({ count: 0 }); + assert.equal(result.calls.length, 1); + assert.deepEqual(result.writes, []); +}); + + +test('scheduled polling never initializes checks for unrelated path-filtered PRs', async () => { + const result = await execute({ checks: [] }); + assert.deepEqual(result.writes, []); + assert.equal(result.calls.filter(([kind]) => kind === 'runs').length, 0); +}); + +test('fork feedback explains the lack of merge validation and upstream fallback', async () => { + const { writes } = await execute(); + assert.match(writes[0].output.summary, /Arrow has no merge queue/); + assert.match(writes[0].output.summary, /CI: Upstream/); + assert.match(writes[0].output.summary, /informational/); +}); + +function evaluate(expression, github) { + expression = expression.replaceAll('needs.check-enabled', "needs['check-enabled']").replace(/^\$\{\{\s*|\s*\}\}$/g, '') + .replace('github.event.pull_request.labels.*.name', 'github.event.pull_request.labels.map(label => label.name)'); + return new Function('github', 'contains', 'needs', 'return (' + expression + ');')( + github, (items, value) => items.includes(value), { 'check-enabled': { outputs: { is_enabled: 'true' } } }); +} +function context(extra = {}) { + return { + repository: 'apache/arrow', event_name: 'pull_request', base_ref: 'main', + ref: 'refs/pull/10/merge', sha, workflow: 'C++', + event: { pull_request: { number: 10, title: 'Fix a C++ bug', labels: [], + head: { repo: { full_name: 'contributor/arrow' } } } }, + ...extra, + }; +} +function routesToBuilds(ctx) { + return Object.values(ciWorkflow.jobs).map(job => Boolean(evaluate(job.if, ctx))); +} +test('all regular C++ jobs defer fork PRs to main, and allow the upstream override', () => { + const ctx = context(); + assert.ok(routesToBuilds(ctx).every(enabled => !enabled)); + ctx.event.pull_request.labels.push({ name: 'CI: Upstream' }); + assert.ok(routesToBuilds(ctx).every(Boolean)); + ctx.event.pull_request.labels = []; + assert.ok(routesToBuilds(ctx).every(enabled => !enabled)); +}); +test('fork push/manual, main push, release PRs, and same-repository PRs keep running', () => { + for (const ctx of [ + context({ repository: 'contributor/arrow', event_name: 'push' }), + context({ repository: 'contributor/arrow', event_name: 'workflow_dispatch' }), + context({ event_name: 'push' }), + context({ base_ref: 'maint-26.0.0' }), + context({ event: { pull_request: { title: '', labels: [], head: { repo: { full_name: 'apache/arrow' } } } } }), + ]) assert.ok(routesToBuilds(ctx).every(Boolean)); +}); +test('WIP suppression remains in effect even with the upstream label', () => { + const ctx = context(); + ctx.event.pull_request.title = 'WIP fix'; + ctx.event.pull_request.labels = [{ name: 'CI: Upstream' }]; + assert.ok(routesToBuilds(ctx).every(enabled => !enabled)); +}); +test('PR label changes trigger routing and push path filters remain intact', () => { + const events = ciWorkflow.on || ciWorkflow.true; + assert.ok(events.pull_request.types.includes('labeled')); + assert.deepEqual(events.push.paths, events.pull_request.paths); + assert.ok(Object.hasOwn(events, 'workflow_dispatch')); +}); +function group(ctx) { + return ciWorkflow.concurrency.group.replace(/\$\{\{\s*(.*?)\s*\}\}/g, + (_, expression) => evaluate(expression, ctx)); +} +test('successive fork pushes and manual runs cancel each other only on the same branch', () => { + const fork = context({ repository: 'contributor/arrow', event_name: 'push', + event: { pull_request: { number: '' } }, ref: 'refs/heads/feature' }); + assert.equal(group(fork), group({ ...fork, sha: 'new', event_name: 'workflow_dispatch' })); + assert.notEqual(group(fork), group({ ...fork, ref: 'refs/heads/other' })); +}); +test('different PRs and main commits never cancel each other', () => { + const pr = context(); + assert.notEqual(group(pr), group({ ...pr, event: { pull_request: { number: 11 } } })); + const main = context({ event_name: 'push', event: { pull_request: { number: '' } } }); + assert.notEqual(group(main), group({ ...main, sha: 'new' })); +}); +test('the privileged bridge never checks out or executes contributor code', () => { + assert.deepEqual(bridgeWorkflow.permissions, { actions: 'read', checks: 'write', 'pull-requests': 'read' }); + assert.equal(bridgeWorkflow.jobs.bridge.steps.length, 1); + assert.match(bridgeWorkflow.jobs.bridge.steps[0].uses, /^actions\/github-script@/); + assert.ok((bridgeWorkflow.on || bridgeWorkflow.true).workflow_run.workflows.includes('C++')); +}); + +test('all ordinary build workflows follow fork routing and retain manual execution', () => { + for (const file of ['archery', 'cpp', 'dev', 'docs', 'docs_light', 'integration', + 'matlab', 'python', 'r', 'ruby', 'release_candidate', 'verify_rc']) { + const config = workflow(file + '.yml'); + const events = config.on || config.true; + assert.ok(Object.hasOwn(events, 'workflow_dispatch'), file); + assert.ok(events.pull_request.types.includes('labeled'), file); + const jobs = file === 'verify_rc' ? [config.jobs.target] : Object.values(config.jobs); + for (const job of jobs) { + const ctx = context(); + assert.equal(Boolean(evaluate(job.if, ctx)), false, file); + ctx.event.pull_request.labels.push({ name: 'CI: Upstream' }); + assert.equal(Boolean(evaluate(job.if, ctx)), true, file); + assert.equal(Boolean(evaluate(job.if, context({ + repository: 'contributor/arrow', event_name: 'workflow_dispatch', + }))), true, file); + assert.equal(Boolean(evaluate(job.if, context({ base_ref: 'maint-26.0.0' }))), true, file); + assert.equal(Boolean(evaluate(job.if, context({ event_name: 'push' }))), file !== 'docs_light', file); + } + } +}); + +test('requested Extra builds move to forks while specialized hardware stays upstream', () => { + for (const file of ['cpp_extra', 'r_extra', 'package_linux']) { + const config = workflow(file + '.yml'); + for (const [name, job] of Object.entries(config.jobs)) { + if (!job.if?.includes('needs.check-enabled.outputs.is_enabled')) continue; + const specialized = name === 'large-memory'; + assert.equal(Boolean(evaluate(job.if, context())), specialized, file + '/' + name); + assert.equal(Boolean(evaluate(job.if, context({ + repository: 'contributor/arrow', event_name: 'workflow_dispatch', + }))), !specialized, file + '/' + name); + const upstream = context(); + upstream.event.pull_request.labels.push({ name: 'CI: Upstream' }); + assert.equal(Boolean(evaluate(job.if, upstream)), true, file + '/' + name); + } + } +}); + +test('different workflow completion notifications do not cancel each other', () => { + const group = (id) => bridgeWorkflow.concurrency.group.replace(/\$\{\{\s*(.*?)\s*\}\}/g, + (_, expression) => evaluate(expression, context({ event: { workflow_run: { + head_repository: { full_name: 'contributor/arrow' }, head_branch: 'feature', workflow_id: id, + } } }))); + assert.notEqual(group(1), group(2)); +}); + +test('similarly named documentation workflows produce separate checks using one run listing', async () => { + const checks = ['docs.yml', 'docs_light.yml'].map((file, index) => ({ + ...seed, id: 200 + index, external_id: 'arrow-fork-ci:10:' + file, + })); + const result = await execute({ checks, runs: [ + run(3, { path: '.github/workflows/docs.yml', conclusion: 'failure' }), + run(4, { path: '.github/workflows/docs_light.yml' }), + run(5), + ] }); + assert.deepEqual(result.writes.map(check => [check.check_run_id, check.conclusion]), + [[200, 'failure'], [201, 'success']]); + assert.equal(result.calls.filter(([kind]) => kind === 'runs').length, 1); + const init = await execute({ event: 'workflow_run', checks: [], + notification: { path: '.github/workflows/docs_light.yml' }, runs: [ + run(4, { path: '.github/workflows/docs_light.yml' }), + ], + }); + assert.equal(init.writes.length, 1); + assert.equal(init.writes[0].name, 'Docs Light (fork)'); +}); + +test('unrequested Extra workflow notifications do not ask contributors to run them', async () => { + for (const labels of [[], [{ name: 'CI: Extra: R' }], [{ name: 'CI: Extra: C++' }], [{ name: 'CI: Extra' }]]) { + const result = await execute({ event: 'workflow_run', checks: [], + notification: { path: '.github/workflows/cpp_extra.yml' }, + candidates: [{ ...pr, labels }], runs: [run(3, { path: '.github/workflows/cpp_extra.yml' })], + }); + assert.equal(result.writes.length, + labels.some(label => ['CI: Extra', 'CI: Extra: C++'].includes(label.name)) ? 1 : 0); + } +}); + +test('missing workflows share their listing and unrelated workflow results cannot turn checks green', async () => { + const result = await execute({ checks: [seed, { + ...seed, id: 200, external_id: 'arrow-fork-ci:10:python.yml', + }], runs: [run(10, { path: '.github/workflows/dev.yml' })] }); + assert.deepEqual(result.writes.map(check => check.conclusion), ['neutral', 'action_required']); + assert.equal(result.calls.filter(([kind]) => kind === 'runs').length, 1); + assert.equal(result.calls.filter(([kind]) => kind === 'workflows').length, 1); +}); + +test('an unrelated label cannot cancel upstream override builds already running', () => { + const running = context(); + running.event.pull_request.labels.push({ name: 'CI: Upstream' }); + const labeled = { ...running, event: { ...running.event, action: 'labeled', label: { name: 'documentation' } } }; + assert.notEqual(group(running), group(labeled)); + assert.ok(routesToBuilds(labeled).every(enabled => !enabled)); +}); diff --git a/docs/source/developers/bug_reports.rst b/docs/source/developers/bug_reports.rst index 1fcc0bb07e03..bb5793d61274 100644 --- a/docs/source/developers/bug_reports.rst +++ b/docs/source/developers/bug_reports.rst @@ -250,6 +250,38 @@ A pull request opened beyond that limit is automatically closed by a GitHub Actions workflow, with a comment explaining why. Once one of your other pull requests has been merged or closed, you can reopen it. +.. _fork-ci: + +CI on contributor forks ++++++++++++++++++++++++ + +For fork pull requests targeting ``main``, builds and tests run on the +contributor's fork. Enable workflows in your fork's **Actions** tab before +pushing. Each ``(fork)`` check links to that workflow's run for the current +branch head. Results refresh after upstream workflow completion and periodically; +scheduled updates can be delayed. Rerun failed jobs in your fork. + +If no run exists for the current commit (for example, an edit did not match a +workflow's push path filters), run the missing workflow on your branch: + +.. code-block:: shell + + gh workflow run cpp.yml --repo YOUR-LOGIN/arrow --ref YOUR-BRANCH + +When a maintainer requests ``CI: Extra`` builds, push again or manually run +``cpp_extra.yml``, ``r_extra.yml``, or ``package_linux.yml`` as requested. +An upstream label cannot start a run in another user's fork. Subsequent +matching pushes discover the open PR's Extra labels automatically. +For ``verify_rc.yml``, also supply ``-f rc_tag=apache-arrow-VERSION-rcN``. + +Fork checks are informational: contributors control their workflow definitions, +and these runs test the branch head rather than its merge with ``main``. +Arrow has no merge queue. Maintainers can apply ``CI: Upstream`` to run +the requested workflows against the PR merge ref upstream; subsequent pushes +also run upstream while the label remains. Main, tags, nightly builds, and +release-branch PRs retain their existing validation. CUDA and the C++ +large-memory job continue to use Arrow's specialized runners upstream. + .. _issue-assignment: Issue assignment From bfe477b66e4d47ed455af8d7e6514621f2022a1f Mon Sep 17 00:00:00 2001 From: sdf-jkl Date: Thu, 29 Oct 2026 10:33:12 -0400 Subject: [PATCH 2/2] ci: quote concurrency expressions containing labels --- .github/workflows/archery.yml | 2 +- .github/workflows/cpp.yml | 2 +- .github/workflows/dev.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/docs_light.yml | 2 +- .github/workflows/integration.yml | 2 +- .github/workflows/matlab.yml | 2 +- .github/workflows/python.yml | 2 +- .github/workflows/r.yml | 2 +- .github/workflows/release_candidate.yml | 2 +- .github/workflows/ruby.yml | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/archery.yml b/.github/workflows/archery.yml index e8d599dedfb1..780e1cc5a5c1 100644 --- a/.github/workflows/archery.yml +++ b/.github/workflows/archery.yml @@ -46,7 +46,7 @@ env: concurrency: # Cancel superseded fork pushes; unrelated labels must not cancel running builds. - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} + group: "${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }}" cancel-in-progress: true permissions: diff --git a/.github/workflows/cpp.yml b/.github/workflows/cpp.yml index 42857d584765..4a08fc38a7af 100644 --- a/.github/workflows/cpp.yml +++ b/.github/workflows/cpp.yml @@ -64,7 +64,7 @@ on: concurrency: # Cancel superseded fork pushes; unrelated labels must not cancel running builds. - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} + group: "${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }}" cancel-in-progress: true permissions: diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index c1bb7755f55e..031fc0542348 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -31,7 +31,7 @@ on: concurrency: # Cancel superseded fork pushes; unrelated labels must not cancel running builds. - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} + group: "${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }}" cancel-in-progress: true permissions: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 64383c09408e..325a2a80c76f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -34,7 +34,7 @@ on: concurrency: # Cancel superseded fork pushes; unrelated labels must not cancel running builds. - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} + group: "${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }}" cancel-in-progress: true permissions: diff --git a/.github/workflows/docs_light.yml b/.github/workflows/docs_light.yml index 4c32ecbe00bf..8bf3736bacdb 100644 --- a/.github/workflows/docs_light.yml +++ b/.github/workflows/docs_light.yml @@ -48,7 +48,7 @@ on: concurrency: # Cancel superseded fork pushes; unrelated labels must not cancel running builds. - group: ${{ github.workflow }}-light-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} + group: "${{ github.workflow }}-light-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }}" cancel-in-progress: true permissions: diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 13f00ba64ab2..b804ce0fcad2 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -50,7 +50,7 @@ on: concurrency: # Cancel superseded fork pushes; unrelated labels must not cancel running builds. - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} + group: "${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }}" cancel-in-progress: true permissions: diff --git a/.github/workflows/matlab.yml b/.github/workflows/matlab.yml index a63b84ed777d..ea1c1f9618c6 100644 --- a/.github/workflows/matlab.yml +++ b/.github/workflows/matlab.yml @@ -40,7 +40,7 @@ on: concurrency: # Cancel superseded fork pushes; unrelated labels must not cancel running builds. - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} + group: "${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }}" cancel-in-progress: true permissions: diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index c4cd0ac81b5a..a17fe6d4cd99 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -46,7 +46,7 @@ on: concurrency: # Cancel superseded fork pushes; unrelated labels must not cancel running builds. - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} + group: "${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }}" cancel-in-progress: true permissions: diff --git a/.github/workflows/r.yml b/.github/workflows/r.yml index 19c2c2c6e963..e3bb159aefab 100644 --- a/.github/workflows/r.yml +++ b/.github/workflows/r.yml @@ -54,7 +54,7 @@ on: concurrency: # Cancel superseded fork pushes; unrelated labels must not cancel running builds. - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} + group: "${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }}" cancel-in-progress: true permissions: diff --git a/.github/workflows/release_candidate.yml b/.github/workflows/release_candidate.yml index 04c4fd7c7470..80f7acbb215c 100644 --- a/.github/workflows/release_candidate.yml +++ b/.github/workflows/release_candidate.yml @@ -38,7 +38,7 @@ on: concurrency: # Cancel superseded fork pushes; unrelated labels must not cancel running builds. - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} + group: "${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }}" cancel-in-progress: true permissions: diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 8c94614d2cc0..4b2b03b9e036 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -58,7 +58,7 @@ on: concurrency: # Cancel superseded fork pushes; unrelated labels must not cancel running builds. - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }} + group: "${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || (github.repository != 'apache/arrow' && github.ref) || github.sha }}-${{ github.event.action == 'labeled' && github.event.label.name != 'CI: Upstream' }}" cancel-in-progress: true permissions: