This is an automated email from the ASF dual-hosted git repository.

andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git


The following commit(s) were added to refs/heads/main by this push:
     new f29a236128 ci: add a Required Checks aggregator job so main can have a 
required status check (#5842)
f29a236128 is described below

commit f29a236128b3c0b175bb77b5b1f5192356c3056a
Author: Andy Grove <[email protected]>
AuthorDate: Fri Sep 11 08:17:02 2026 -0600

    ci: add a Required Checks aggregator job so main can have a required status 
check (#5842)
    
    * ci: move the job routing policy out of ci.yml expressions and into 
compute-changes.py
    
    Every heavy job in `ci.yml` carried a four-line `${{ }}` expression 
combining
    a path-filter output, an event-name test, an opt-in label test, and a 
special
    case for `labeled` events. Ten jobs, ten near-identical copies, none of them
    testable outside a real workflow run.
    
    Fold that policy into `dev/ci/compute-changes.py` next to the path filters 
it
    was already being ANDed with. Each job's gate becomes:
    
        if: needs.changes.outputs.spark_3_5 == 'true'
    
    and the routing lives in one readable table:
    
        POLICY = {
            "spark_3_5": ["pr", "push"],
            "spark_3_4": ["push", "label:run-spark-3.4-tests"],
            ...
        }
    
    `ci.yml` loses 62 lines net. No behaviour changes: the `changes` job now
    receives the event name, action, added label and PR labels, and applies the
    same rules the expressions did.
    
    The point of moving it is that it can now be tested. `check-ci-config.py`
    grows `POLICY_CASES`, which pins the expected job set for each event shape 
--
    including that a non-gating label such as dependabot's `dependencies` starts
    nothing, which is issue #5007 and was previously only enforceable by reading
    YAML carefully.
    
    Writing `"pr"` alongside a `"label:"` tier reads as "runs on every PR, and 
also
    when labelled" but the label check wins and the "pr" is dead, so
    `check-ci-config.py` now rejects that combination rather than letting it be 
a
    silent no-op.
    
    Verified equivalent with a differential harness: the pre-refactor `if:`
    expressions were extracted from ci.yml at the merge base, translated to 
Python,
    and evaluated against the new POLICY over 1464 (job, event) combinations --
    every event name, pull_request action, label set and added-label pairing. 
Zero
    mismatches, and three seeded regressions (renaming a gating label, dropping 
a
    label gate, dropping the labeled-event narrowing) are each caught.
    
    Groundwork for #5838: adding the merge queue then means adding one tier to
    POLICY rather than editing ten YAML expressions.
    
    * ci: add a Required Checks aggregator job so main can have a required 
status check
    
    `main` currently requires no status checks, only one approving review. 
Adding
    one is blocked by how the umbrella workflow reports: every heavy job in
    `ci.yml` is a thin caller of a reusable workflow, and the check name a 
caller
    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
                        friends, and no bare `PR Build (Linux)` at all
    
    Confirmed against bdd2aeb (md-only, everything skipped) and 36caf8e (heavy
    jobs ran). No name is reported in both cases, so requiring the bare name 
would
    block every code change and requiring a nested name would block every 
docs-only
    change. Both hang waiting for a check that never arrives rather than 
failing,
    and a required context that never reports also blocks the merge that would 
fix
    `.asf.yaml` -- only INFRA can remove a required check by hand at that point.
    
    Add `required_checks`, a flat job that `needs:` every other job and reports 
on
    every event. It runs `if: always()` and treats `skipped` as a pass, so it 
goes
    red only when an upstream job reports `failure` or `cancelled`.
    
    Nothing requires it yet. This lands on its own so the check can be observed 
on
    real pull requests before `.asf.yaml` names it, which is the step that is
    expensive to get wrong.
    
    `dev/ci/check-ci-config.py` gains two invariants: every `ci.yml` job except
    `docs` must appear in `required_checks.needs`, and once `.asf.yaml` does
    declare a required context, the job's `name:` must match it.
    
    Part of #5838.
    
    * ci: give the Required Checks job an empty permissions block
    
    The aggregator reads only the `needs` context, so it has no use for the
    GITHUB_TOKEN. Declaring `permissions: {}` on the job drops the token
    entirely and clears the CodeQL "Workflow does not contain permissions"
    finding, without touching the token scope any other job in ci.yml gets.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01BtAqq4YJsk8uk42c7vHk8B
    
    * ci: tighten the required-checks invariants in check-ci-config.py
    
    Widen the job-id regexes to accept `-` and uppercase, which GitHub allows.
    A `spark-4-2:` job missing from `required_checks.needs` passed the coverage
    check silently before this change.
    
    Scope the `.asf.yaml` parse to the `main:` entry under `protected_branches`.
    It previously collected every `contexts:` list in the file, so a release
    branch requiring its own context produced a false failure that blamed main.
    
    Drop the em dashes from the new README prose.
    
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
    Claude-Session: https://claude.ai/code/session_01BtAqq4YJsk8uk42c7vHk8B
    
    * ci: retry or tolerate infra-only step failures on the required path
    
    Once Required Checks is a required context, any red job evicts the PR from
    the merge queue. The first two runs on this branch each failed in a step
    that runs after the test verdict is known or before any test has started,
    and in neither case was a test involved.
    
    - java-test: mark the test-report upload continue-on-error. It runs on green
      jobs, nothing downstream consumes the reports, and a FinalizeArtifact 403
      must not turn a passing run red. The failure-only uploads are unchanged.
    - iceberg_spark_test_reusable: route the shard-inventory upload through
      upload-artifact-retry. iceberg-spark-shard-coverage consumes it, so it
      already fell under the README rule for artifacts a later job downloads.
    - pr_build_linux: split Lint Scala (syntactic) into a retried no-op
      `cs launch scalafix -- --version` that populates the cache, then the real
      check under `--mode offline`, so a nonzero exit there is only ever a lint
      violation.
    - ci.yml preflight: retry the actionlint download and fetch the installer
      to a file instead of piping it into bash.
    
    Closes #5860
    
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
    Claude-Session: https://claude.ai/code/session_01BtAqq4YJsk8uk42c7vHk8B
    
    * ci: publish label-run verdicts under a name nothing requires
    
    ci.yml also fires on `labeled`, and on that event POLICY skips the PR tier
    because it already ran at the same commit. The label run's aggregate says
    nothing about the commit's applicable suites, but GitHub keeps only the most
    recent check run per name per commit, so publishing it as `Required Checks`
    would let a `dependencies` label that lands a minute after a push mark the
    commit green while the real run is still going (the mechanism from #5007).
    
    Make the aggregator's `name:` an expression that routes label runs to
    `Required Checks (label run)`. Skipping the job on `labeled` would not help:
    a skipped check run still carries the name and still counts as passing.
    
    check-ci-config.py now requires that expression shape, rejects a `labeled`
    branch equal to the required name, and rejects an `.asf.yaml` that requires
    the label-run name. The commit-run name is what has to match `.asf.yaml`.
    
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
    Claude-Session: https://claude.ai/code/session_01BtAqq4YJsk8uk42c7vHk8B
    
    ---------
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .github/actions/java-test/action.yaml             |   6 +-
 .github/workflows/README.md                       |  85 +++++++--
 .github/workflows/ci.yml                          |  87 ++++++++-
 .github/workflows/iceberg_spark_test_reusable.yml |   4 +-
 .github/workflows/pr_build_linux.yml              |  21 ++-
 dev/ci/check-ci-config.py                         | 206 +++++++++++++++++++++-
 6 files changed, 384 insertions(+), 25 deletions(-)

diff --git a/.github/actions/java-test/action.yaml 
b/.github/actions/java-test/action.yaml
index ef640da3e2..42078b3c44 100644
--- a/.github/actions/java-test/action.yaml
+++ b/.github/actions/java-test/action.yaml
@@ -138,7 +138,10 @@ runs:
       if: failure()
       # These three stay on the plain action rather than
       # ../upload-artifact-retry: a local action calling another local action
-      # is untested in this repo, and these only run on already-failing jobs.
+      # is untested in this repo. The two failure-only uploads run on jobs that
+      # are already red. The test-report upload also runs on green jobs, so it
+      # is continue-on-error: a FinalizeArtifact 403 must not turn a passing 
test
+      # run into a red check, and nothing downstream consumes the reports.
       uses: actions/upload-artifact@v7
       with:
         name: crash-logs-${{ inputs.artifact_name }}
@@ -165,6 +168,7 @@ runs:
         if-no-files-found: ignore
     - name: Upload test results
       if: ${{ !cancelled() && inputs.upload-test-reports == 'true' }}
+      continue-on-error: true
       uses: actions/upload-artifact@v7
       with:
          name: java-test-reports-${{ inputs.artifact_name }}
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index 935e11272d..f98918b24a 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -42,6 +42,15 @@ is a `workflow_call` reusable invoked from the umbrella.
   spark_4_1                                                     iceberg_1_10 
run-iceberg-tests
   iceberg_1_11
 
+        |                                   |                                  
 |
+        
+-----------------------------------+-----------------------------------+
+                                            v
+                                +-----------------------+
+                                |    required_checks    |  ubuntu-slim
+                                |  one flat name that   |
+                                |  is safe to require   |
+                                +-----------------------+
+
   reusable workflows invoked via `uses:`:
     pr_build_linux.yml         spark_sql_test_reusable.yml
     pr_build_macos.yml         iceberg_spark_test_reusable.yml
@@ -67,6 +76,7 @@ is a `workflow_call` reusable invoked from the umbrella.
 | `iceberg_1_8`        | push, **or** PR with `run-iceberg-tests` label      | 
Iceberg sources                     |
 | `iceberg_1_9`        | push, **or** PR with `run-iceberg-tests` label      | 
Iceberg sources                     |
 | `iceberg_1_10`       | push, **or** PR with `run-iceberg-tests` label      | 
Iceberg sources                     |
+| `required_checks`    | always, after every job above except `docs`         | 
none (always runs)                  |
 
 A heavy job appears in the PR's checks list as a `skipped` entry whenever
 its path filter or event criteria don't match. Skipped checks count as
@@ -93,6 +103,14 @@ Two rules keep those runs from corrupting the PR's status:
 - On a `labeled` event, `POLICY` reports false for every job the new label does
   not gate. Without that, applying a single label re-ran the entire heavy
   pipeline at a commit that had already been tested.
+- On a `labeled` event, `required_checks` publishes its verdict as
+  `Required Checks (label run)`, not `Required Checks`. Because the PR tier is
+  skipped on that event, the label run's aggregate says nothing about the
+  commit's applicable suites, and GitHub keeps only the most recent check run
+  per name per commit. Under the real name, a `dependencies` label landing a
+  minute after a push would mark the commit green while the commit run was
+  still going. Skipping the job would not help either, since a skipped check
+  run still carries the name and still counts as passing.
 
 `run-spark-4.1-tests` gates nothing: `spark_4_1` already runs on every PR.
 
@@ -198,10 +216,26 @@ though the content already uploaded. Its client only 
retries
 built-in step retry. Use `./.github/actions/upload-artifact-retry` instead for
 any artifact a later job consumes: same inputs and outputs, three attempts,
 15s then 45s backoff. Attempts 2 and 3 force `overwrite: true`, so the name
-must belong to exactly one producer in the run (see above). The diagnostic
-uploads inside `./.github/actions/java-test` stay on the plain action, since a
-local action calling another local action is untested here and those run only
-on already-failing jobs.
+must belong to exactly one producer in the run (see above). The uploads inside
+`./.github/actions/java-test` stay on the plain action, since a local action
+calling another local action is untested here. Its two failure-only uploads run
+on jobs that are already red. Its test-report upload also runs on green jobs
+and is `continue-on-error: true`: nothing downstream consumes the reports, and
+a `FinalizeArtifact` 403 must not turn a passing test run into a red check.
+
+**Tool downloads.** `Lint Scala (syntactic)` splits the coursier download from
+the lint: a `Fetch scalafix` step retries a no-op `cs launch ... -- --version`
+three times, and the check itself then runs `cs launch --mode offline` against
+the populated cache, so a nonzero exit there can only be a lint violation.
+`preflight` retries the actionlint download the same way, and fetches the
+installer to a file rather than piping it into `bash` so a truncated download
+cannot run a partial script.
+
+Every one of these steps runs after the test verdict is already known, or
+before any test has started. Once `Required Checks` is a required context
+(see below), a red job from any of them evicts the PR from the merge queue,
+which is why plain network flakes are worth retrying rather than re-running
+the whole pipeline by hand.
 
 **Maven wrapper bootstrap.** `./.github/actions/java-test` retries
 `./mvnw --version` with exponential backoff, so a failed download of the Maven
@@ -209,17 +243,32 @@ distribution does not surface as a test failure.
 
 ## Branch protection
 
-Required-check names changed when these workflows were consolidated. The
-umbrella exposes per-job names like `CI / pr_build_linux / Lint`,
-`CI / spark_3_5 / linux-test (...)`, etc. Update repository branch
-protection rules to point at the new names; the old standalone workflow
-names (`Spark SQL Tests (Spark 3.5)`, `PR Build (Linux)`, ...) no longer
-exist as top-level workflows.
-
-`.asf.yaml` currently declares no `required_status_checks` for `main`, only
-`required_approving_review_count: 1`. Anything added there must be a name that
-never legitimately reports `skipped`, because GitHub counts a skipped check as
-passing. The bare caller-job names (`PR Build (Linux)`, `Spark SQL Tests
-(Spark 3.5)`, ...) are not such names: a reusable workflow that actually runs
-publishes only its child jobs, so the bare name shows up solely when the caller
-was skipped.
+`.asf.yaml` declares no `required_status_checks` for `main` today, only
+`required_approving_review_count: 1`.
+
+Adding one is not as simple as naming a job, because a caller of a reusable
+workflow publishes a _different check name_ depending on whether it ran:
+
+| Caller state     | Check runs published                                     |
+| ---------------- | -------------------------------------------------------- |
+| skipped by `if:` | one run named exactly `PR Build (Linux)`, `skipped`      |
+| ran              | only `PR Build (Linux) / Spark 4.1, JDK 17 [exec]`, etc. |
+
+No name is reported in both cases. Requiring the bare name would block every
+code change; requiring a nested name would block every docs-only change. Both
+hang waiting for a check that never arrives rather than failing, and a required
+context that never reports also blocks the merge that would fix `.asf.yaml`.
+Recovering from that needs an INFRA Jira ticket.
+
+The `required_checks` job at the bottom of `ci.yml` exists to be the one name
+that is safe to require. It is flat, so it reports on every event; it runs
+`if: always()` and treats `skipped` as a pass, so it only goes red when an
+upstream job reports `failure` or `cancelled`.
+
+`dev/ci/check-ci-config.py` enforces that every `ci.yml` job except `docs`
+appears in `required_checks.needs`, and that the job's `name:` is the
+expression that routes `labeled` runs to a separate name (see "Label events"
+above). Once `.asf.yaml` does declare a required context for `main`, it also
+enforces that the commit-run name still matches it and that the label-run name
+is never the one required. All of that is silent when broken and expensive to
+recover from.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f53d854ba6..1bb96a43ab 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -99,9 +99,24 @@ jobs:
         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: |
-          curl -sSfL 
https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash
 | bash
-          echo "$PWD" >> $GITHUB_PATH
+          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
@@ -305,3 +320,71 @@ jobs:
       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.
+  #
+  # Nothing requires it yet. It is landing first, on its own, so that we can
+  # watch it report on real pull requests before `.asf.yaml` names it in
+  # `required_status_checks` for `main`.
+  #
+  # 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
diff --git a/.github/workflows/iceberg_spark_test_reusable.yml 
b/.github/workflows/iceberg_spark_test_reusable.yml
index 6a268bc129..fe1e4757cd 100644
--- a/.github/workflows/iceberg_spark_test_reusable.yml
+++ b/.github/workflows/iceberg_spark_test_reusable.yml
@@ -162,7 +162,9 @@ jobs:
             -Pquick=true -x javadoc
       - name: Upload Iceberg shard inventory and test reports
         if: ${{ !cancelled() }}
-        uses: actions/upload-artifact@v7
+        # iceberg-spark-shard-coverage downloads the inventory, so a flaky
+        # upload here fails that job too. Retry rather than fail on the first 
403.
+        uses: ./.github/actions/upload-artifact-retry
         with:
           name: iceberg-spark-${{ inputs.iceberg-full }}-spark-${{ 
inputs.spark-full }}-scala-${{ inputs.scala }}-jdk${{ inputs.java }}-shard-${{ 
matrix.shard }}-attempt-${{ github.run_attempt }}
           path: |
diff --git a/.github/workflows/pr_build_linux.yml 
b/.github/workflows/pr_build_linux.yml
index ac9e75c5af..58c04f406a 100644
--- a/.github/workflows/pr_build_linux.yml
+++ b/.github/workflows/pr_build_linux.yml
@@ -63,9 +63,28 @@ jobs:
         with:
           jvm: temurin:21
 
+      # Split the network from the lint. `cs launch` resolves and downloads the
+      # scalafix artifacts from Maven Central, which resets connections now and
+      # then, so the warm-up retries a no-op `--version` run. The real check 
then
+      # runs `--mode offline` against the populated cache: no network, and a
+      # nonzero exit can only mean a lint violation, never a download failure.
+      - name: Fetch scalafix
+        run: |
+          for attempt in 1 2 3; do
+            if cs launch scalafix:0.14.6 -- --version; then
+              break
+            fi
+            if [ "$attempt" = 3 ]; then
+              echo "::error::scalafix download failed after 3 attempts."
+              exit 1
+            fi
+            echo "::warning::scalafix download failed (attempt $attempt of 3); 
retrying in $((attempt * 15))s."
+            sleep $((attempt * 15))
+          done
+
       - name: Run syntactic scalafix check (no compile)
         run: |
-          cs launch scalafix:0.14.6 -- \
+          cs launch --mode offline scalafix:0.14.6 -- \
             --check \
             --syntactic \
             --config .scalafix-syntactic.conf \
diff --git a/dev/ci/check-ci-config.py b/dev/ci/check-ci-config.py
index 5b8c9d6e0c..9d5c0b3c48 100644
--- a/dev/ci/check-ci-config.py
+++ b/dev/ci/check-ci-config.py
@@ -15,7 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
-# Guards three CI invariants that are silent when broken:
+# Guards four CI invariants that are silent when broken:
 #
 #   1. Change-filter routing. dev/ci/compute-changes.py decides which heavy
 #      jobs run. A file that a job depends on but that no filter lists makes
@@ -28,7 +28,17 @@
 #      expected sets are transcribed from the `if:` expressions ci.yml carried
 #      before the policy moved, so a regression here is a behaviour change.
 #
-#   3. Artifact-name uniqueness. Artifact names are scoped to the *run*, not
+#   3. Required-check coverage. `Required Checks` in ci.yml is the job that
+#      `.asf.yaml` can name in `required_status_checks` for main. A heavy job
+#      missing from its `needs:` can fail without blocking the merge, and a
+#      rename on either side of the ci.yml/.asf.yaml pair turns the required
+#      context into one that never reports, which blocks *every* merge to main
+#      until INFRA removes it by hand. The job's name must also route `labeled`
+#      runs, which skip the PR tier by design, to a name nothing requires:
+#      GitHub keeps the most recent check run per name per commit, so a label
+#      run publishing the required name would overwrite the real verdict.
+#
+#   4. Artifact-name uniqueness. Artifact names are scoped to the *run*, not
 #      to the calling workflow, and ci.yml calls the Spark SQL and Iceberg
 #      reusable workflows several times in one run. Two producers sharing a
 #      name make `download-artifact` pick by highest artifact ID rather than
@@ -43,6 +53,23 @@ import sys
 from pathlib import Path
 
 WORKFLOWS = Path(".github/workflows")
+ASF_YAML = Path(".asf.yaml")
+
+# The ci.yml job that aggregates every other job's result.
+AGGREGATOR_JOB = "required_checks"
+
+# The aggregator's `name:` has to be this expression shape: label runs publish
+# the first literal, every other event the second. See the comment on the job.
+AGGREGATOR_NAME_EXPR = re.compile(
+    r"^\$\{\{\s*github\.event\.action\s*==\s*'labeled'\s*&&\s*"
+    r"'([^']+)'\s*\|\|\s*'([^']+)'\s*\}\}$"
+)
+
+# Jobs that legitimately stay out of the aggregator's `needs:`. `docs` deploys
+# to asf-site on push to main; it gates nothing and is never part of a merge
+# decision, so folding it in would only turn a failed site deploy into a red
+# `Required Checks` on main.
+AGGREGATOR_EXEMPT = {AGGREGATOR_JOB, "docs"}
 
 # Changed-file list -> the set of outputs compute-changes.py must report true.
 # Every other output must be false. Keep one case per shared build input so a
@@ -259,10 +286,185 @@ def check_artifact_names():
     return not failures
 
 
+def ci_jobs_and_aggregator():
+    """Return (all job ids in ci.yml, aggregator `needs:` ids, aggregator 
display name).
+
+    Parsed line by line rather than with PyYAML, which is not installed on the
+    preflight runner.
+    """
+    lines = (WORKFLOWS / "ci.yml").read_text(encoding="utf-8").splitlines()
+    # GitHub job ids may contain letters, digits, `-` and `_`. Matching only
+    # snake_case would let a `spark-4-2:` job escape the coverage check with no
+    # error at all, which is the silent failure this checker exists to catch.
+    job_key = re.compile(r"^  ([A-Za-z0-9_-]+):\s*$")
+    needs_item = re.compile(r"^      - ([A-Za-z0-9_-]+)\s*$")
+    name_key = re.compile(r"^    name:\s*(\S.*?)\s*$")
+
+    jobs, needs, display_name = [], [], None
+    in_jobs = False
+    current = None
+    in_needs_list = False
+    for line in lines:
+        if line.startswith("jobs:"):
+            in_jobs = True
+            continue
+        if not in_jobs:
+            continue
+        match = job_key.match(line)
+        if match:
+            current = match.group(1)
+            jobs.append(current)
+            in_needs_list = False
+            continue
+        if current != AGGREGATOR_JOB:
+            continue
+        if line.strip() == "needs:":
+            in_needs_list = True
+            continue
+        match = name_key.match(line)
+        if match:
+            display_name = match.group(1)
+        match = needs_item.match(line)
+        if in_needs_list and match:
+            needs.append(match.group(1))
+        elif in_needs_list and line.strip() and not line.startswith("      "):
+            in_needs_list = False
+    return jobs, needs, display_name
+
+
+def asf_required_contexts():
+    """Return the required_status_checks contexts .asf.yaml declares for main.
+
+    Scoped to the `main:` entry under `protected_branches:`. Release branches
+    (`branch-0.x`) have their own entries, and a context one of those requires
+    says nothing about what `main` requires.
+    """
+    lines = ASF_YAML.read_text(encoding="utf-8").splitlines()
+    branch_key = re.compile(r"^    ([^\s:#][^:]*):\s*$")
+    context_item = re.compile(r'^\s+- "?(.+?)"?\s*$')
+    contexts = []
+    in_protected = False
+    in_main = False
+    collecting = False
+    for line in lines:
+        stripped = line.strip()
+        if stripped.startswith("#"):
+            continue
+        if stripped == "protected_branches:":
+            in_protected = True
+            continue
+        if not in_protected:
+            continue
+        # A new top-level or `github:`-level key ends the protected_branches 
map.
+        if stripped and not line.startswith("    "):
+            break
+        match = branch_key.match(line)
+        if match:
+            in_main = match.group(1) == "main"
+            collecting = False
+            continue
+        if not in_main:
+            continue
+        if stripped == "contexts:":
+            collecting = True
+            continue
+        if not collecting:
+            continue
+        match = context_item.match(line)
+        if match:
+            contexts.append(match.group(1))
+        else:
+            collecting = False
+    return contexts
+
+
+def aggregator_check_names(raw_name):
+    """Split the aggregator's `name:` into (required name, label-run name).
+
+    Returns (None, None) when the value is not the expected expression, which
+    includes a plain literal: a literal name is published on `labeled` runs 
too,
+    and that is the failure mode the expression exists to prevent.
+    """
+    match = AGGREGATOR_NAME_EXPR.match(raw_name)
+    if not match:
+        return None, None
+    return match.group(2), match.group(1)
+
+
+def check_required_checks():
+    jobs, needs, display_name = ci_jobs_and_aggregator()
+    failures = []
+
+    if AGGREGATOR_JOB not in jobs:
+        failures.append(
+            f"ci.yml has no `{AGGREGATOR_JOB}` job; it is the only context "
+            f"`.asf.yaml` requires for main"
+        )
+    else:
+        missing = [j for j in jobs if j not in AGGREGATOR_EXEMPT and j not in 
needs]
+        if missing:
+            failures.append(
+                f"ci.yml jobs missing from `{AGGREGATOR_JOB}.needs`: "
+                f"{', '.join(missing)}. A job outside the aggregator can fail "
+                f"without blocking the merge queue; add it, or add it to "
+                f"AGGREGATOR_EXEMPT here with a reason"
+            )
+        stale = [n for n in needs if n not in jobs]
+        if stale:
+            failures.append(
+                f"`{AGGREGATOR_JOB}.needs` names jobs that no longer exist in "
+                f"ci.yml: {', '.join(stale)}"
+            )
+        if display_name is None:
+            failures.append(f"the `{AGGREGATOR_JOB}` job in ci.yml has no 
`name:`")
+        else:
+            required_name, label_run_name = 
aggregator_check_names(display_name)
+            if required_name is None:
+                failures.append(
+                    f"`{AGGREGATOR_JOB}.name` is {display_name!r}; it must be 
the "
+                    f"expression `${{{{ github.event.action == 'labeled' && "
+                    f"'<label-run name>' || '<required name>' }}}}`. A label 
run "
+                    f"skips the PR tier by design, and GitHub keeps only the 
most "
+                    f"recent check run per name, so publishing the required 
name "
+                    f"from a label run would overwrite the commit run's 
verdict"
+                )
+            elif label_run_name == required_name:
+                failures.append(
+                    f"`{AGGREGATOR_JOB}.name` publishes '{required_name}' on "
+                    f"label runs too; the `labeled` branch of the expression 
must "
+                    f"be a different name"
+                )
+            else:
+                # An empty list means main does not require any status check
+                # yet, which is a valid state: there is nothing to keep in 
sync.
+                # Once a context is declared, it has to be the one this job
+                # reports on commit runs, and never the label-run name.
+                contexts = asf_required_contexts()
+                if contexts and required_name not in contexts:
+                    failures.append(
+                        f"`{AGGREGATOR_JOB}` publishes the check name "
+                        f"'{required_name}', but .asf.yaml requires {contexts} 
"
+                        f"for main. A required context that never reports 
blocks every "
+                        f"merge, including the one that would fix .asf.yaml"
+                    )
+                if label_run_name in contexts:
+                    failures.append(
+                        f".asf.yaml requires '{label_run_name}', which is the 
name "
+                        f"`{AGGREGATOR_JOB}` publishes only on label runs. 
Those runs "
+                        f"skip the PR tier, so requiring it would let a label 
mark an "
+                        f"untested commit green"
+                    )
+
+    for failure in failures:
+        print(f"required checks: {failure}")
+    return not failures
+
+
 if __name__ == "__main__":
     ok = check_change_filters()
     ok = check_event_policy() and ok
     ok = check_artifact_names() and ok
+    ok = check_required_checks() and ok
     if not ok:
         sys.exit(1)
     print("CI config checks passed")


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to