ci: skip unaffected language jobs in coverage baseline (#3822)

Every master push ran all 7 language coverage suites plus the
macOS cache warm (~55 runner-minutes) even when the push touched
a single SDK or only docs. Every Codecov flag has carryforward
enabled and all statuses are informational, so re-measuring
untouched languages re-uploads identical data.

A detect job now diffs the push range and gates each job on the
paths feeding its Codecov flag; untouched flags carry forward
their previous master report. The differ fails open to a full
run on any uncertainty (initial push, force push, CI or tooling
changes), and a new workflow_dispatch trigger forces a full
baseline on demand. The macOS cache warm follows the rust gate
since that cache only goes stale when Rust sources change.
diff --git a/.github/workflows/coverage-baseline.yml b/.github/workflows/coverage-baseline.yml
index 35aab11..b23d314 100644
--- a/.github/workflows/coverage-baseline.yml
+++ b/.github/workflows/coverage-baseline.yml
@@ -15,29 +15,73 @@
 # specific language governing permissions and limitations
 # under the License.
 
-# Full coverage baseline for all 7 languages (Rust, Java, C#, Python,
-# PHP, Node, Go). Runs on every push to master so Codecov has complete data
-# for carryforward on PR builds where only a subset of SDKs is tested.
+# Coverage baseline for all 7 languages (Rust, Java, C#, Python, PHP,
+# Node, Go). Runs on push to master so Codecov has data on master SHAs for
+# carryforward on PR builds where only a subset of SDKs is tested.
+#
+# Jobs are path-gated: a language job runs only when the paths feeding its
+# Codecov flag changed in the push (scripts/ci/coverage-baseline-affected.sh).
+# Untouched flags carry forward their previous master report, which stays
+# accurate because their sources did not change. workflow_dispatch forces a
+# full run (manual backfill).
 
 name: Coverage baseline
 
 on:
   push:
     branches: [master]
+  workflow_dispatch:
 
 permissions:
   contents: read
 
+# Per-event groups so a manual backfill run is not cancelled by the next
+# merge push (and vice versa).
 concurrency:
-  group: coverage-baseline-${{ github.ref }}
+  group: coverage-baseline-${{ github.event_name }}-${{ github.ref }}
   cancel-in-progress: true
 
 env:
   IGGY_CI_BUILD: true
 
 jobs:
+  detect:
+    name: Detect affected coverage jobs
+    # workflow_dispatch offers a branch picker, but baseline uploads and
+    # cache warms only make sense for master. Skipping detect skips every
+    # downstream job.
+    if: github.ref == 'refs/heads/master'
+    runs-on: ubuntu-latest
+    timeout-minutes: 10
+    outputs:
+      rust: ${{ steps.affected.outputs.rust }}
+      java: ${{ steps.affected.outputs.java }}
+      csharp: ${{ steps.affected.outputs.csharp }}
+      python: ${{ steps.affected.outputs.python }}
+      php: ${{ steps.affected.outputs.php }}
+      node: ${{ steps.affected.outputs.node }}
+      go: ${{ steps.affected.outputs.go }}
+    steps:
+      - uses: actions/checkout@v7.0.1
+        with:
+          # The gate diffs against the pre-push tip, which a shallow clone
+          # does not have.
+          fetch-depth: 0
+
+      - name: Compute affected gates
+        id: affected
+        # github.event.before is empty on workflow_dispatch; the script
+        # fails open to all-true there.
+        run: >-
+          scripts/ci/coverage-baseline-affected.sh
+          "${{ github.event.before }}" "${{ github.sha }}"
+          >> "$GITHUB_OUTPUT"
+        shell: bash
+
   rust-coverage:
     name: Rust coverage baseline
+    needs: detect
+    if: needs.detect.outputs.rust == 'true'
     runs-on: ubuntu-latest
     timeout-minutes: 45
     steps:
@@ -108,6 +152,8 @@
 
   java-coverage:
     name: Java coverage baseline
+    needs: detect
+    if: needs.detect.outputs.java == 'true'
     runs-on: ubuntu-latest
     timeout-minutes: 30
     steps:
@@ -151,6 +197,8 @@
 
   csharp-coverage:
     name: C# coverage baseline
+    needs: detect
+    if: needs.detect.outputs.csharp == 'true'
     runs-on: ubuntu-latest
     timeout-minutes: 30
     steps:
@@ -223,6 +271,8 @@
 
   python-coverage:
     name: Python coverage baseline
+    needs: detect
+    if: needs.detect.outputs.python == 'true'
     runs-on: ubuntu-latest
     timeout-minutes: 30
     steps:
@@ -316,6 +366,8 @@
 
   php-coverage:
     name: PHP coverage baseline
+    needs: detect
+    if: needs.detect.outputs.php == 'true'
     runs-on: ubuntu-latest
     timeout-minutes: 30
     steps:
@@ -337,6 +389,8 @@
 
   node-coverage:
     name: Node coverage baseline
+    needs: detect
+    if: needs.detect.outputs.node == 'true'
     runs-on: ubuntu-latest
     timeout-minutes: 30
     steps:
@@ -404,6 +458,8 @@
 
   go-coverage:
     name: Go coverage baseline
+    needs: detect
+    if: needs.detect.outputs.go == 'true'
     runs-on: ubuntu-latest
     timeout-minutes: 30
     steps:
@@ -478,6 +534,11 @@
 
   warm-cache-macos:
     name: Warm macOS cache
+    needs: detect
+    # Gated on the rust key: the cache goes stale only when Rust sources
+    # change, and the Swatinem key is Cargo.lock-based, so skipping the save
+    # on non-Rust pushes leaves the previous cache valid.
+    if: needs.detect.outputs.rust == 'true'
     runs-on: macos-14
     timeout-minutes: 30
     steps:
diff --git a/scripts/ci/coverage-baseline-affected.sh b/scripts/ci/coverage-baseline-affected.sh
new file mode 100755
index 0000000..60cc3bd
--- /dev/null
+++ b/scripts/ci/coverage-baseline-affected.sh
@@ -0,0 +1,96 @@
+#!/usr/bin/env bash
+# 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.
+
+set -euo pipefail
+
+# Decide which coverage-baseline jobs a master push needs. Every Codecov flag
+# has carryforward enabled, so a language whose sources did not change keeps
+# serving its previous master report; only changed surfaces are re-measured.
+#
+# Fail-open: on any uncertainty (initial push, force push, unknown base) emit
+# true for every gate so a needed refresh is never skipped. workflow_dispatch
+# passes an empty base and lands on that path by design (manual full backfill).
+#
+# Usage:  coverage-baseline-affected.sh <base-sha> <head-sha>
+# Output: one "<gate>=true|false" line per gate on stdout, shaped for
+#         appending to $GITHUB_OUTPUT. Diagnostics go to stderr.
+
+BASE="${1:-}"
+HEAD="${2:-}"
+ZERO="0000000000000000000000000000000000000000"
+GATES=(rust java csharp python php node go)
+
+emit_all() {
+  local gate
+  for gate in "${GATES[@]}"; do
+    echo "${gate}=true"
+  done
+}
+
+if [[ -z "$HEAD" ]]; then
+  echo "usage: $0 <base-sha> <head-sha>" >&2
+  exit 2
+fi
+
+# Unusable base: initial push, force push, workflow_dispatch (empty base),
+# or a commit not in history.
+if [[ -z "$BASE" || "$BASE" == "$ZERO" ]] || ! git cat-file -e "${BASE}^{commit}" 2>/dev/null; then
+  echo "coverage-gate: unusable base '${BASE:-<empty>}', running all jobs" >&2
+  emit_all
+  exit 0
+fi
+
+# CI plumbing changes how every job builds and measures; re-baseline all.
+if ! git diff --quiet "$BASE" "$HEAD" -- .github scripts codecov.yml; then
+  echo "coverage-gate: CI/tooling change, running all jobs" >&2
+  emit_all
+  exit 0
+fi
+
+# A git failure here (exit > 1, e.g. corrupt object) reads as "changed",
+# falling open for that gate.
+changed() {
+  ! git diff --quiet "$BASE" "$HEAD" -- "$@"
+}
+
+# GATES is the single source for both this loop and emit_all, so a gate can
+# never be present on one path and missing on the other. A GATES entry
+# without a pathspec arm fails loud instead of reusing the previous
+# iteration's paths.
+for gate in "${GATES[@]}"; do
+  case "$gate" in
+    rust) paths=(core Cargo.toml Cargo.lock rust-toolchain.toml .cargo) ;;
+    java) paths=(foreign/java) ;;
+    csharp) paths=(foreign/csharp) ;;
+    python) paths=(foreign/python) ;;
+    php) paths=(foreign/php) ;;
+    node) paths=(foreign/node) ;;
+    # The go job also runs the bdd/go suite with foreign/go in -coverpkg.
+    go) paths=(foreign/go bdd/go) ;;
+    *)
+      echo "coverage-gate: no pathspecs defined for gate '$gate'" >&2
+      exit 1
+      ;;
+  esac
+  if changed "${paths[@]}"; then
+    echo "${gate}=true"
+    echo "coverage-gate: ${gate} affected" >&2
+  else
+    echo "${gate}=false"
+  fi
+done