| # 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. |
| |
| # Top-level CI orchestrator: runs cheap preflight checks first, then fans out |
| # to the long-running test/build workflows only if preflight passed and the |
| # PR/push touched files relevant to that workflow. |
| |
| name: CI |
| |
| # A `labeled` event (e.g. the run-spark-*-tests gates, or dependabot's automatic |
| # `dependencies` label added ~1s after open) fires at the same commit as the |
| # opened/synchronize run. Keying the group on the label name keeps labeled runs |
| # in their own subgroup so they never cancel the real commit run; opened and |
| # synchronize both map to `commit` so a new push still supersedes its predecessor. |
| # Event names also isolate queue validation from push and manual runs. |
| concurrency: |
| group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.action == 'labeled' && github.event.label.name || 'commit' }} |
| cancel-in-progress: true |
| |
| on: |
| pull_request: |
| types: [opened, synchronize, reopened, labeled] |
| merge_group: |
| types: [checks_requested] |
| push: |
| branches: |
| - main |
| workflow_dispatch: |
| |
| jobs: |
| # --------------------------------------------------------------------------- |
| # preflight: cheap checks that gate everything else. Failure short-circuits |
| # the entire pipeline before any heavy job spins up. Folds in what used to be |
| # pr_rat_check, pr_markdown_format, pr_missing_suites, and validate_workflows. |
| # pr_title_check stays a standalone workflow because it needs to fire on PR |
| # `edited` events. |
| # |
| # This job deliberately carries no `if:`. A job held back by `if:` still |
| # publishes a check run under its own name with conclusion `skipped`, and the |
| # newest check run for a name is the one the merge box, `gh pr checks` and |
| # required-status-check evaluation read. Guarding this job on the label name |
| # therefore let any non-gating label overwrite the commit run's real |
| # `Preflight` verdict with `skipped` (issue #5007). Running it unconditionally |
| # costs about a minute of ubuntu-slim time per label event and keeps the |
| # reported verdict truthful. Skipping the redundant work is the heavy jobs' |
| # job; POLICY in dev/ci/compute-changes.py drops every job a `labeled` event |
| # does not gate. |
| # --------------------------------------------------------------------------- |
| preflight: |
| name: Preflight |
| runs-on: ubuntu-slim |
| steps: |
| - uses: actions/checkout@v7 |
| |
| - name: Set up Java |
| uses: actions/setup-java@v6 |
| with: |
| distribution: temurin |
| java-version: 11 |
| |
| - name: Apache RAT license check |
| run: ./mvnw -B -N apache-rat:check |
| |
| - name: Setup Node.js |
| uses: actions/setup-node@v7 |
| with: |
| node-version: '24' |
| |
| - name: Install prettier |
| run: npm install -g prettier |
| |
| - name: Check markdown formatting |
| run: prettier --check "**/*.md" |
| |
| - name: Check missing suites |
| run: python3 dev/ci/check-suites.py |
| |
| - name: Check micro benchmark runner |
| run: python3 dev/ci/check-benchmark-runner.py |
| |
| - name: Check pull request type labeling |
| run: node --test dev/ci/pr-type-label.test.mjs |
| |
| - name: Check Iceberg shard inventory validation |
| run: python3 dev/ci/test-iceberg-shards.py |
| |
| - name: Check CI config invariants |
| run: python3 dev/ci/check-ci-config.py |
| |
| - name: Install actionlint |
| # Pure network, and preflight gates every other job, so a single reset |
| # connection here would fail the whole run. Download to a file rather |
| # than pipe into bash so a failed download cannot run a partial script. |
| run: | |
| for attempt in 1 2 3; do |
| if curl -sSfL --retry 3 --retry-all-errors -o download-actionlint.bash \ |
| https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash \ |
| && bash download-actionlint.bash; then |
| break |
| fi |
| if [ "$attempt" = 3 ]; then |
| echo "::error::actionlint download failed after 3 attempts." |
| exit 1 |
| fi |
| echo "::warning::actionlint download failed (attempt $attempt of 3); retrying in $((attempt * 10))s." |
| sleep $((attempt * 10)) |
| done |
| echo "$PWD" >> "$GITHUB_PATH" |
| |
| - name: Lint GitHub Actions workflows |
| run: actionlint -color --shellcheck=off |
| |
| # --------------------------------------------------------------------------- |
| # changes: compute which long jobs need to run for this event. Replaces the |
| # per-workflow `on: paths:` filters that used to gate triggering. Filter |
| # rules live in dev/ci/compute-changes.py, which is invoked here in lieu of |
| # dorny/paths-filter (not on the apache org actions allow list). On |
| # workflow_dispatch every output is forced true so a manual run can |
| # exercise any gated job. |
| # --------------------------------------------------------------------------- |
| changes: |
| name: Detect changes |
| needs: preflight |
| runs-on: ubuntu-slim |
| outputs: |
| build_linux: ${{ steps.compute.outputs.build_linux }} |
| build_macos: ${{ steps.compute.outputs.build_macos }} |
| benchmark: ${{ steps.compute.outputs.benchmark }} |
| docs: ${{ steps.compute.outputs.docs }} |
| spark_3_4: ${{ steps.compute.outputs.spark_3_4 }} |
| spark_3_5: ${{ steps.compute.outputs.spark_3_5 }} |
| spark_4_0: ${{ steps.compute.outputs.spark_4_0 }} |
| spark_4_1: ${{ steps.compute.outputs.spark_4_1 }} |
| iceberg_1_8: ${{ steps.compute.outputs.iceberg_1_8 }} |
| iceberg_1_9: ${{ steps.compute.outputs.iceberg_1_9 }} |
| iceberg_1_10: ${{ steps.compute.outputs.iceberg_1_10 }} |
| iceberg_1_11: ${{ steps.compute.outputs.iceberg_1_11 }} |
| steps: |
| - uses: actions/checkout@v7 |
| with: |
| # Need history for PR, push, and merge-group base/head diffs. |
| fetch-depth: 0 |
| |
| - name: Compute outputs |
| id: compute |
| shell: bash |
| env: |
| EVENT_NAME: ${{ github.event_name }} |
| EVENT_ACTION: ${{ github.event.action }} |
| LABEL_NAME: ${{ github.event.label.name }} |
| PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} |
| PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} |
| PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} |
| PUSH_BEFORE: ${{ github.event.before }} |
| PUSH_AFTER: ${{ github.sha }} |
| QUEUE_BASE_SHA: ${{ github.event.merge_group.base_sha }} |
| QUEUE_HEAD_SHA: ${{ github.event.merge_group.head_sha }} |
| run: | |
| set -euo pipefail |
| : > changed_files.txt |
| if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then |
| # No meaningful base to diff against; compute-changes.py forces |
| # every output true for this event so a manual run can exercise |
| # any gated job. |
| : |
| elif [[ "$EVENT_NAME" == "pull_request" ]]; then |
| git diff --name-only "$PR_BASE_SHA"..."$PR_HEAD_SHA" > changed_files.txt |
| elif [[ "$EVENT_NAME" == "merge_group" ]]; then |
| # Include every PR in the group, relative to its base snapshot. |
| git diff --name-only "${QUEUE_BASE_SHA:?}".."${QUEUE_HEAD_SHA:?}" > changed_files.txt |
| elif [[ "$EVENT_NAME" == "push" ]]; then |
| # push to main; first push to a branch has all-zero before sha |
| if [[ "$PUSH_BEFORE" =~ ^0+$ ]]; then |
| git ls-tree -r --name-only "$PUSH_AFTER" > changed_files.txt |
| else |
| git diff --name-only "$PUSH_BEFORE".."$PUSH_AFTER" > changed_files.txt |
| fi |
| else |
| echo "::error::Unsupported CI event: $EVENT_NAME" |
| exit 1 |
| fi |
| echo "Changed files:" |
| cat changed_files.txt |
| python3 dev/ci/compute-changes.py changed_files.txt >> "$GITHUB_OUTPUT" |
| |
| # --------------------------------------------------------------------------- |
| # Heavy jobs: each is a thin caller of an existing reusable workflow, gated |
| # on the one `changes` output that covers it. |
| # |
| # That single output already folds in everything these gates used to spell |
| # out inline: which files the job cares about, which events may run it, and |
| # which opt-in label it needs on a pull request. All of it lives in FILTERS |
| # and POLICY in dev/ci/compute-changes.py, where it is one table instead of |
| # ten near-identical `${{ }}` expressions, and where dev/ci/check-ci-config.py |
| # can actually test it. |
| # --------------------------------------------------------------------------- |
| |
| pr_build_linux: |
| name: PR Build (Linux) |
| needs: changes |
| if: needs.changes.outputs.build_linux == 'true' |
| uses: ./.github/workflows/pr_build_linux.yml |
| |
| pr_build_macos: |
| name: PR Build (macOS) |
| needs: changes |
| if: needs.changes.outputs.build_macos == 'true' |
| uses: ./.github/workflows/pr_build_macos.yml |
| |
| pr_benchmark_check: |
| name: PR Benchmark Check |
| needs: changes |
| if: needs.changes.outputs.benchmark == 'true' |
| uses: ./.github/workflows/pr_benchmark_check.yml |
| |
| docs: |
| name: Deploy Comet site |
| needs: changes |
| # docs deploys to asf-site, so only run on push-to-main (or a manual dispatch). |
| if: needs.changes.outputs.docs == 'true' |
| uses: ./.github/workflows/docs.yaml |
| |
| spark_3_4: |
| name: Spark SQL Tests (Spark 3.4) |
| needs: changes |
| # Main-only by default; PRs need the `run-spark-3.4-tests` label. |
| if: needs.changes.outputs.spark_3_4 == 'true' |
| uses: ./.github/workflows/spark_sql_test_reusable.yml |
| with: |
| spark-short: '3.4' |
| spark-full: '3.4.3' |
| java: 11 |
| |
| spark_3_5: |
| name: Spark SQL Tests (Spark 3.5) |
| needs: changes |
| if: needs.changes.outputs.spark_3_5 == 'true' |
| uses: ./.github/workflows/spark_sql_test_reusable.yml |
| with: |
| spark-short: '3.5' |
| spark-full: '3.5.9' |
| java: 17 |
| |
| spark_4_0: |
| name: Spark SQL Tests (Spark 4.0) |
| needs: changes |
| # Main-only by default; PRs need the `run-spark-4.0-tests` label. Swapped |
| # with spark_4_1 on the `oom` branch to validate the memory caps against |
| # Spark 4.1 by default. |
| if: needs.changes.outputs.spark_4_0 == 'true' |
| uses: ./.github/workflows/spark_sql_test_reusable.yml |
| with: |
| spark-short: '4.0' |
| spark-full: '4.0.4' |
| java: 17 |
| |
| spark_4_1: |
| name: Spark SQL Tests (Spark 4.1) |
| needs: changes |
| if: needs.changes.outputs.spark_4_1 == 'true' |
| uses: ./.github/workflows/spark_sql_test_reusable.yml |
| with: |
| spark-short: '4.1' |
| spark-full: '4.1.3' |
| java: 17 |
| |
| iceberg_1_8: |
| name: Iceberg Spark SQL Tests (Iceberg 1.8) |
| needs: changes |
| # Main-only by default; PRs need the `run-iceberg-tests` label. |
| if: needs.changes.outputs.iceberg_1_8 == 'true' |
| uses: ./.github/workflows/iceberg_spark_test_reusable.yml |
| with: |
| iceberg-short: '1.8' |
| iceberg-full: '1.8.1' |
| spark-short: '3.4' |
| spark-full: '3.4.3' |
| java: 11 |
| |
| iceberg_1_9: |
| name: Iceberg Spark SQL Tests (Iceberg 1.9) |
| needs: changes |
| # Main-only by default; PRs need the `run-iceberg-tests` label. |
| if: needs.changes.outputs.iceberg_1_9 == 'true' |
| uses: ./.github/workflows/iceberg_spark_test_reusable.yml |
| with: |
| iceberg-short: '1.9' |
| iceberg-full: '1.9.1' |
| spark-short: '3.5' |
| spark-full: '3.5.9' |
| java: 17 |
| |
| iceberg_1_10: |
| name: Iceberg Spark SQL Tests (Iceberg 1.10) |
| needs: changes |
| # Main-only by default; PRs need the `run-iceberg-tests` label. Iceberg 1.11 |
| # (Spark 4.1) is the PR-gated Iceberg job; 1.10 covers the Spark 3.5 path. |
| if: needs.changes.outputs.iceberg_1_10 == 'true' |
| uses: ./.github/workflows/iceberg_spark_test_reusable.yml |
| with: |
| iceberg-short: '1.10' |
| iceberg-full: '1.10.0' |
| spark-short: '3.5' |
| spark-full: '3.5.9' |
| java: 17 |
| |
| iceberg_1_11: |
| name: Iceberg Spark SQL Tests (Iceberg 1.11) |
| needs: changes |
| # Runs on every PR: Iceberg 1.11 is our only Spark 4.1 Iceberg coverage. |
| if: needs.changes.outputs.iceberg_1_11 == 'true' |
| uses: ./.github/workflows/iceberg_spark_test_reusable.yml |
| with: |
| iceberg-short: '1.11' |
| iceberg-full: '1.11.0' |
| spark-short: '4.1' |
| spark-full: '4.1.3' |
| java: 17 |
| |
| # --------------------------------------------------------------------------- |
| # required_checks: one flat job that aggregates every other job's result, so |
| # that `main` has a single name it can safely require. |
| # |
| # `.asf.yaml` requires this context on both pull requests and merge groups. |
| # |
| # None of the jobs above can be required directly, because the name a caller |
| # of a reusable workflow publishes depends on whether it ran: |
| # |
| # skipped by `if:` one check run named exactly `PR Build (Linux)` |
| # actually ran only `PR Build (Linux) / Spark 4.1, JDK 17 [exec]`, |
| # ... and no bare `PR Build (Linux)` at all |
| # |
| # So requiring the bare name would block every code change, and requiring a |
| # nested name would block every docs-only change. Both hang rather than fail, |
| # and a required context that never reports also locks `.asf.yaml` itself, |
| # which then needs an INFRA ticket to unwedge. Aggregating into one flat job, |
| # whose name is published on every event, avoids the whole class of problem. |
| # |
| # `if: always()` is what makes this work: without it the job inherits the |
| # default `success()` and is itself skipped the moment any dependency fails. |
| # |
| # The name is an expression because `ci.yml` also fires on `labeled`, and on |
| # that event POLICY deliberately skips the PR tier (it already ran at this |
| # commit). A label run's verdict therefore says nothing about the commit's |
| # applicable suites, yet GitHub keeps only the most recent check run per name |
| # per commit, so publishing it as `Required Checks` would let a `dependencies` |
| # label turn a still-running or red commit run green (issue #5007). Label |
| # runs publish under a name nothing requires instead. Skipping the job here |
| # would not help: a skipped check run still carries the name and still counts |
| # as passing. dev/ci/check-ci-config.py enforces both halves of this. |
| # --------------------------------------------------------------------------- |
| required_checks: |
| name: ${{ github.event.action == 'labeled' && 'Required Checks (label run)' || 'Required Checks' }} |
| if: always() |
| # Reads only the `needs` context, so it needs no token access at all. |
| permissions: {} |
| needs: |
| - preflight |
| - changes |
| - pr_build_linux |
| - pr_build_macos |
| - pr_benchmark_check |
| - spark_3_4 |
| - spark_3_5 |
| - spark_4_0 |
| - spark_4_1 |
| - iceberg_1_8 |
| - iceberg_1_9 |
| - iceberg_1_10 |
| - iceberg_1_11 |
| runs-on: ubuntu-slim |
| steps: |
| - name: Summarize upstream results |
| env: |
| NEEDS: ${{ toJSON(needs) }} |
| run: echo "$NEEDS" |
| |
| # `skipped` is a pass: it means the change did not touch anything that |
| # job covers. Only `failure` and `cancelled` block the merge. |
| - name: Fail if any upstream job failed or was cancelled |
| if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') |
| run: | |
| echo "::error::One or more upstream jobs did not succeed. See the results above." |
| exit 1 |