blob: 5136be1dc614fc5569c992e52e0b1d0ce1aa97db [file]
# 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