diff --git a/.github/actions/java-test/action.yaml b/.github/actions/java-test/action.yaml index 42078b3c44d..d4cc384a93a 100644 --- a/.github/actions/java-test/action.yaml +++ b/.github/actions/java-test/action.yaml @@ -62,12 +62,13 @@ runs: shell: bash run: echo "hash=${{ hashFiles('**/pom.xml') }}" >> "$GITHUB_OUTPUT" - - name: Cache Maven dependencies + - name: Restore Maven dependencies # TODO: remove next line after working again # temporarily work around https://github.com/actions/runner-images/issues/13341 # by disabling caching for macOS if: ${{ runner.os != 'macOS' }} - uses: actions/cache@v5 + id: maven-cache + uses: actions/cache/restore@v5 with: path: | ~/.m2/repository @@ -176,3 +177,16 @@ runs: if-no-files-found: ignore retention-days: 7 # 1 week for test reports overwrite: true + + # Saved only on main, and only on a green run: an entry written from a pull request or a + # `gh-readonly-queue/*` branch cannot be restored by any later run, and it + # evicts main's from the shared budget. See "Large caches are written on + # main only" in ../../workflows/README.md. + - name: Save Maven dependencies + if: ${{ runner.os != 'macOS' && github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v5 + with: + path: | + ~/.m2/repository + /root/.m2/repository + key: ${{ runner.os }}-java-maven-${{ steps.maven-cache-key.outputs.hash }} diff --git a/.github/actions/rust-test/action.yaml b/.github/actions/rust-test/action.yaml index c39c2dcd4f9..2d1fb0bb9a1 100644 --- a/.github/actions/rust-test/action.yaml +++ b/.github/actions/rust-test/action.yaml @@ -41,8 +41,9 @@ runs: cd native cargo install cargo-machete --version 0.7.0 && cargo machete - - name: Cache Maven dependencies - uses: actions/cache@v4 + - name: Restore Maven dependencies + id: maven-cache + uses: actions/cache/restore@v4 with: path: | ~/.m2/repository @@ -57,6 +58,19 @@ runs: cd common ../mvnw -B clean compile -DskipTests + # Saved only on main: an entry written from a pull request or a + # `gh-readonly-queue/*` branch cannot be restored by any later run, and it + # evicts main's from the shared budget. See "Large caches are written on + # main only" in ../../workflows/README.md. + - name: Save Maven dependencies + if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + ~/.m2/repository + /root/.m2/repository + key: ${{ runner.os }}-rust-maven-${{ hashFiles('**/pom.xml') }} + - name: Install nextest shell: bash run: | diff --git a/.github/actions/setup-spark-builder/action.yaml b/.github/actions/setup-spark-builder/action.yaml index 84804c0a798..40f3a1e4a8e 100644 --- a/.github/actions/setup-spark-builder/action.yaml +++ b/.github/actions/setup-spark-builder/action.yaml @@ -51,15 +51,21 @@ runs: cd apache-spark git apply ../dev/diffs/${{inputs.spark-version}}.diff - - name: Cache Maven dependencies - uses: actions/cache@v4 + - name: Restore Maven dependencies + id: maven-cache + uses: actions/cache/restore@v4 with: path: | ~/.m2/repository /root/.m2/repository key: ${{ runner.os }}-spark-sql-${{ hashFiles('common/**/pom.xml', 'spark/**/pom.xml') }} + # No Spark SQL workflow has a push-to-main tier, so nothing writes + # `spark-sql-` any more (see the save step below). `java-maven-` is the + # entry the TPC-H/TPC-DS jobs write on push to main; a Maven repository + # is always safe to start from a superset or a subset of itself. restore-keys: | ${{ runner.os }}-spark-sql- + ${{ runner.os }}-java-maven- - name: Build Comet (with native) if: ${{ inputs.skip-native-build != 'true' }} @@ -93,3 +99,18 @@ runs: "$(dirname "$pom")/_remote.repositories" done done + + # No Spark SQL workflow has a push-to-main tier today, so in practice this + # never fires. The guard is here so the step starts writing if one is ever + # added, and never writes from a pull request or a `gh-readonly-queue/*` + # branch, where the entry cannot be restored by any later run and only + # evicts main's from the shared budget. See "Large caches are written on + # main only" in ../../workflows/README.md. + - name: Save Maven dependencies + if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + ~/.m2/repository + /root/.m2/repository + key: ${{ runner.os }}-spark-sql-${{ hashFiles('common/**/pom.xml', 'spark/**/pom.xml') }} diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 0f1f93fe94e..3b8f9c56a65 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -337,6 +337,73 @@ than once carries its version inputs, e.g. and also that every `download-artifact` name is produced by an upload in the same workflow. +## Large caches are written on main only + +An `actions/cache` entry is scoped to the ref that wrote it. A run can restore +entries from its own ref and from the default branch, and nothing else. So a +cache written from `refs/pull/*/merge` is visible only to another run of that +same pull request, and one written from the merge queue's +`gh-readonly-queue/*` branch is visible to nobody at all, because the queue +deletes that branch when it is done with it. + +Both still count against the repository's shared cache budget, which is +evicted least-recently-used. Writing them therefore has no upside and one +large downside: it pushes main's entries out, and main's entries are the only +ones a future pull request can use. + +That is what happened. On 2026-09-15 the repository held 12.27 GiB across 14 +entries: 9.22 GiB on a single `gh-readonly-queue/*` branch, 3.01 GiB on +`refs/pull/*/merge` refs, and nothing whatsoever on main. Every one of the +seven near-identical `Linux-java-maven-*` Maven repositories was a +write-only copy. With main's `cargo-ci` entry evicted, all eight native +builds in a merge-queue run missed their cache and paid a cold ~26 minute +compile; the same build with a hit takes 2m21s. + +The rule, then: any cache holding a Maven repository (`~/.m2/repository`) or a +cargo tree (`~/.cargo/registry`, `~/.cargo/git`, `native/target`) is +**restored everywhere and saved only on push to main**: + +```yaml +- name: Restore Maven dependencies + id: maven-cache + uses: actions/cache/restore@v6 + with: + path: | + ~/.m2/repository + /root/.m2/repository + key: ${{ runner.os }}-java-maven-${{ hashFiles('**/pom.xml') }}-lint + restore-keys: | + ${{ runner.os }}-java-maven- + +# ... the steps that populate it ... + +- name: Save Maven dependencies + if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v6 + with: + path: | + ~/.m2/repository + /root/.m2/repository + key: ${{ runner.os }}-java-maven-${{ hashFiles('**/pom.xml') }}-lint +``` + +The bare `actions/cache@vN` form cannot express this: it saves in an implicit +post step that no `if:` can reach. `dev/ci/check-ci-config.py` rejects it for +any of the paths above, and rejects a `save` that is missing the `github.ref` +guard. `publish_snapshot.yml` is exempt in `CACHE_SAVE_SCOPE_EXEMPT`, because +it runs from main on a schedule already. + +The TPC-H and TPC-DS dataset caches keep the read-write form and are out of +scope entirely: `./tpch` and `./tpcds-sf-1` are a few hundred MB, they are not +dependency trees, and they are keyed on this workflow file, so a pull request +that edits it would regenerate the data on every run rather than once. + +A job that only ever runs on a pull request or in the queue keeps the guard +anyway, and so never writes. That is deliberate — it restores from main's +entry through `restore-keys` and downloads whatever else it needs, which is +what a cold pull request already did. See the push-tier discussion above for +which jobs do run on main and therefore do write. + ## Retrying flaky network operations **Maven.** `.mvn/maven.config` tunes the Maven Resolver HTTP transport: six diff --git a/.github/workflows/pr_benchmark_check.yml b/.github/workflows/pr_benchmark_check.yml index 933714d9d72..bc2559a57fc 100644 --- a/.github/workflows/pr_benchmark_check.yml +++ b/.github/workflows/pr_benchmark_check.yml @@ -59,15 +59,21 @@ jobs: cd native cargo check --benches - - name: Cache Maven dependencies - uses: actions/cache@v6 + - name: Restore Maven dependencies + id: maven-cache + uses: actions/cache/restore@v6 with: path: | ~/.m2/repository /root/.m2/repository key: ${{ runner.os }}-benchmark-maven-${{ hashFiles('**/pom.xml') }} + # This job is queue-only, so nothing writes `benchmark-maven-` any + # more (see the save step below). `java-maven-` is the entry the + # TPC-H/TPC-DS jobs write on push to main; a Maven repository is + # always safe to start from a superset or a subset of itself. restore-keys: | ${{ runner.os }}-benchmark-maven- + ${{ runner.os }}-java-maven- - name: Check Scala compilation and linting # Pin to spark-4.0 (Scala 2.13.16) because the default profile is now @@ -76,3 +82,16 @@ jobs: # the same exclusion in the main lint matrix. run: | ./mvnw -B compile test-compile scalafix:scalafix -Dscalafix.mode=CHECK -Psemanticdb -Pspark-4.0 -DskipTests + + # Saved only on main: an entry written from a pull request or a + # `gh-readonly-queue/*` branch cannot be restored by any later run, and + # it evicts main's from the shared budget. See "Large caches are written + # on main only" in README.md. + - name: Save Maven dependencies + if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v6 + with: + path: | + ~/.m2/repository + /root/.m2/repository + key: ${{ runner.os }}-benchmark-maven-${{ hashFiles('**/pom.xml') }} diff --git a/.github/workflows/pr_build_linux.yml b/.github/workflows/pr_build_linux.yml index cf32f9dad66..1438ff6a2c9 100644 --- a/.github/workflows/pr_build_linux.yml +++ b/.github/workflows/pr_build_linux.yml @@ -191,8 +191,9 @@ jobs: rust-version: ${{ env.RUST_VERSION }} jdk-version: ${{ matrix.profile.java_version }} - - name: Cache Maven dependencies - uses: actions/cache@v6 + - name: Restore Maven dependencies + id: maven-cache + uses: actions/cache/restore@v6 with: path: | ~/.m2/repository @@ -229,6 +230,19 @@ jobs: run: | ./dev/ci/check-working-tree-clean.sh + # Saved only on main: an entry written from a pull request or a + # `gh-readonly-queue/*` branch cannot be restored by any later run, and + # it evicts main's from the shared budget. See "Large caches are written + # on main only" in README.md. + - name: Save Maven dependencies + if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v6 + with: + path: | + ~/.m2/repository + /root/.m2/repository + key: ${{ runner.os }}-java-maven-${{ hashFiles('**/pom.xml') }}-lint + # Compile-only verification for Spark 4.1. Tests are intentionally skipped: the spark-4.1 # profile is currently a build target only, and several runtime/test failures are tracked # in follow-up PRs. Excluded from lint-java because semanticdb-scalac_2.13.17 is not yet @@ -249,8 +263,9 @@ jobs: rust-version: ${{ env.RUST_VERSION }} jdk-version: 17 - - name: Cache Maven dependencies - uses: actions/cache@v6 + - name: Restore Maven dependencies + id: maven-cache + uses: actions/cache/restore@v6 with: path: | ~/.m2/repository @@ -265,6 +280,19 @@ jobs: - name: Compile (skip tests) run: ./mvnw -B install -DskipTests -Dmaven.test.skip=true -Pspark-4.1 + # Saved only on main: an entry written from a pull request or a + # `gh-readonly-queue/*` branch cannot be restored by any later run, and + # it evicts main's from the shared budget. See "Large caches are written + # on main only" in README.md. + - name: Save Maven dependencies + if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v6 + with: + path: | + ~/.m2/repository + /root/.m2/repository + key: ${{ runner.os }}-java-maven-${{ hashFiles('**/pom.xml') }}-spark-4.1-build + celeborn-reflection-compatibility: needs: lint name: Celeborn ${{ matrix.celeborn_version }} reflection compatibility @@ -287,8 +315,9 @@ jobs: rust-version: ${{ env.RUST_VERSION }} jdk-version: 17 - - name: Cache Maven dependencies - uses: actions/cache@v6 + - name: Restore Maven dependencies + id: maven-cache + uses: actions/cache/restore@v6 with: path: | ~/.m2/repository @@ -312,6 +341,19 @@ jobs: -Dsuites=org.apache.comet.shuffle.CelebornReflectionCompatibilitySuite \ -DfailIfNoTests=false + # Saved only on main: an entry written from a pull request or a + # `gh-readonly-queue/*` branch cannot be restored by any later run, and + # it evicts main's from the shared budget. See "Large caches are written + # on main only" in README.md. + - name: Save Maven dependencies + if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v6 + with: + path: | + ~/.m2/repository + /root/.m2/repository + key: ${{ runner.os }}-java-maven-${{ hashFiles('**/pom.xml') }}-celeborn-${{ matrix.celeborn_version }} + # Build native library once and share with all test jobs. Also the owner of # main's `cargo-ci` cache entry, which is why it runs in cache-refresh-only # mode: rebuilding native/target from cold costs every pull request ~20 min. @@ -360,7 +402,7 @@ jobs: uses: actions/cache/save@v6 # The push run is the cache warmer (see the header); a scheduled run # at the same sha would only re-archive an entry that already exists. - if: github.event_name == 'push' + if: github.event_name == 'push' && github.ref == 'refs/heads/main' with: path: | ~/.cargo/registry @@ -409,7 +451,7 @@ jobs: uses: actions/cache/save@v6 # The push run is the cache warmer (see the header); a scheduled run # at the same sha would only re-archive an entry that already exists. - if: github.event_name == 'push' + if: github.event_name == 'push' && github.ref == 'refs/heads/main' with: path: | ~/.cargo/registry @@ -598,8 +640,8 @@ jobs: path: native/target/release/ # Restore cargo registry cache (for any cargo commands that might run) - - name: Cache Cargo registry - uses: actions/cache@v6 + - name: Restore Cargo registry + uses: actions/cache/restore@v6 with: path: | ~/.cargo/registry @@ -646,8 +688,9 @@ jobs: name: native-lib-linux path: native/target/release/ - - name: Cache Maven dependencies - uses: actions/cache@v6 + - name: Restore Maven dependencies + id: maven-cache + uses: actions/cache/restore@v6 with: path: | ~/.m2/repository @@ -682,6 +725,19 @@ jobs: run: | SPARK_HOME=`pwd` SPARK_TPCH_DATA=`pwd`/tpch/sf1_parquet ./mvnw -B -Prelease -Dsuites=org.apache.spark.sql.CometTPCHQuerySuite test + # Saved only on main: an entry written from a pull request or a + # `gh-readonly-queue/*` branch cannot be restored by any later run, and + # it evicts main's from the shared budget. See "Large caches are written + # on main only" in README.md. + - name: Save Maven dependencies + if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v6 + with: + path: | + ~/.m2/repository + /root/.m2/repository + key: ${{ runner.os }}-java-maven-${{ hashFiles('**/pom.xml') }} + # TPC-DS correctness tests - verifies benchmark queries produce correct results. # The three join strategies run sequentially in one job so the project is built # once. Runs in cache-refresh-only mode for the same reason as the TPC-H job; @@ -711,8 +767,9 @@ jobs: name: native-lib-linux path: native/target/release/ - - name: Cache Maven dependencies - uses: actions/cache@v6 + - name: Restore Maven dependencies + id: maven-cache + uses: actions/cache/restore@v6 with: path: | ~/.m2/repository @@ -781,3 +838,16 @@ jobs: SPARK_TPCDS_JOIN_CONF: | spark.sql.autoBroadcastJoinThreshold=-1 spark.sql.join.forceApplyShuffledHashJoin=true + + # Saved only on main: an entry written from a pull request or a + # `gh-readonly-queue/*` branch cannot be restored by any later run, and + # it evicts main's from the shared budget. See "Large caches are written + # on main only" in README.md. + - name: Save Maven dependencies + if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v6 + with: + path: | + ~/.m2/repository + /root/.m2/repository + key: ${{ runner.os }}-java-maven-${{ hashFiles('**/pom.xml') }} diff --git a/.github/workflows/pr_build_macos.yml b/.github/workflows/pr_build_macos.yml index af4adab8634..310a4f04014 100644 --- a/.github/workflows/pr_build_macos.yml +++ b/.github/workflows/pr_build_macos.yml @@ -277,8 +277,8 @@ jobs: path: native/target/release/ # Restore cargo registry cache (for any cargo commands that might run) - - name: Cache Cargo registry - uses: actions/cache@v6 + - name: Restore Cargo registry + uses: actions/cache/restore@v6 with: path: | ~/.cargo/registry diff --git a/.github/workflows/pyarrow_udf_test.yml b/.github/workflows/pyarrow_udf_test.yml index a84fe69e426..a3797f913b5 100644 --- a/.github/workflows/pyarrow_udf_test.yml +++ b/.github/workflows/pyarrow_udf_test.yml @@ -66,8 +66,9 @@ jobs: rust-version: ${{ env.RUST_VERSION }} jdk-version: 17 - - name: Cache Maven dependencies - uses: actions/cache@v6 + - name: Restore Maven dependencies + id: maven-cache + uses: actions/cache/restore@v6 with: path: | ~/.m2/repository @@ -102,3 +103,16 @@ jobs: spark/src/test/resources/pyspark/test_pyarrow_udf.py /tmp/venv/bin/python -m pytest -v \ spark/src/test/resources/pyspark/test_pyarrow_udf_dictionary_shuffle.py + + # Saved only on main: an entry written from a pull request or a + # `gh-readonly-queue/*` branch cannot be restored by any later run, and + # it evicts main's from the shared budget. See "Large caches are written + # on main only" in README.md. + - name: Save Maven dependencies + if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v6 + with: + path: | + ~/.m2/repository + /root/.m2/repository + key: ${{ runner.os }}-java-maven-${{ hashFiles('**/pom.xml') }}-pyarrow-udf-${{ matrix.pyspark }} diff --git a/dev/ci/check-ci-config.py b/dev/ci/check-ci-config.py index b271bc60f65..abb6b1f2d4b 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 six CI invariants that are silent when broken: +# Guards seven 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 @@ -56,6 +56,13 @@ # added to that workflow without the guard starts running on every push # again and nothing fails, so nothing tells you. # +# 7. Cache save scope. actions/cache entries are scoped to the ref that +# wrote them, so one written from a pull request or from the queue's +# throwaway branch can never be restored again -- but it still counts +# against the repository's shared budget and evicts main's entries, +# which is how every native build in the queue came to miss its cargo +# cache and pay a cold ~26 minute compile. +# # Run from the repository root: python3 dev/ci/check-ci-config.py import importlib.util @@ -439,6 +446,35 @@ def guarded_jobs(path, guard): # next line, so match the key alone. PROFILES_INPUT = re.compile(r"^\s+profiles:\s*(>-|\$\{\{)") +# Cache paths big enough that writing one from a throwaway ref costs main its +# own entries. The repository's actions/cache budget is shared and evicted +# least-recently-used, and a Maven repository or a cargo tree runs to gigabytes +# apiece. +# Matched as substrings of the step's `path:`, so they have to survive the +# prefix being an expression: publish_snapshot.yml writes +# `${{ env.CARGO_HOME }}/registry`, which no `~/.cargo/...` literal would +# catch. +LARGE_CACHE_PATHS = ( + ".m2/repository", + ".cargo/registry", + ".cargo/git", + "CARGO_HOME", + "native/target", +) +# `actions/cache@vN` saves in an implicit post step that no `if:` can reach, so +# a large cache has to be split into `restore` plus a guarded `save`. +CACHE_RW_USES = re.compile(r"^\s*(?:-\s*)?uses:\s*actions/cache@v\d+\s*$") +CACHE_SAVE_USES = re.compile(r"^\s*(?:-\s*)?uses:\s*actions/cache/save@v\d+\s*$") +CACHE_MAIN_GUARD = re.compile(r"github\.ref\s*==\s*'refs/heads/main'") +# publish_snapshot.yml runs from main on a schedule, so its entries already +# land in the only scope that helps. (The TPC-H/TPC-DS dataset caches need no +# entry here: `./tpch` and `./tpcds-sf-1` are not dependency trees and are not +# in LARGE_CACHE_PATHS, so this check never looks at them.) +CACHE_SAVE_SCOPE_EXEMPT = { + ("publish_snapshot.yml", "snapshot-cargo-"), + ("publish_snapshot.yml", "snapshot-maven-"), +} + def load_filters(): spec = importlib.util.spec_from_file_location("compute_changes", "dev/ci/compute-changes.py") @@ -1005,6 +1041,96 @@ def check_nightly_base_fallback(): return not failures +def _cache_steps(lines): + """Yield (line_no, uses_line, step_lines) for every actions/cache* step. + + A step runs from the `- ` that opens it to the next line indented no + further, which is enough structure to read its `if:`, `key:` and `path:` + without a YAML parser (no other check here takes that dependency either). + """ + for index, line in enumerate(lines): + if not (CACHE_RW_USES.match(line) or CACHE_SAVE_USES.match(line)): + continue + start = index + while start > 0 and not lines[start].lstrip().startswith("- "): + start -= 1 + indent = len(lines[start]) - len(lines[start].lstrip()) + end = index + 1 + while end < len(lines): + stripped = lines[end].strip() + if stripped and not stripped.startswith("#"): + if len(lines[end]) - len(lines[end].lstrip()) <= indent: + break + end += 1 + yield start + 1, line, lines[start:end] + + +def check_cache_save_scope(): + """A multi-gigabyte cache is written only on push to main. + + Caches are scoped to the ref that wrote them: a pull request reads its own + ref and main, and a `gh-readonly-queue/*` branch takes its entries to the + grave when the queue deletes it. An entry written from a queue or + pull-request ref can therefore never be restored by a later run, while + still counting against the repository's shared budget and pushing main's + entries out of it under least-recently-used eviction. + + That is not hypothetical. On 2026-09-15 the repository held 12.27 GiB + across 14 entries, of which 9.22 GiB sat on one `gh-readonly-queue/*` + branch and 3.01 GiB on `refs/pull/*/merge`. Nothing at all was on main, so + every `cargo build --profile ci` in the merge queue missed its cache and + paid a cold ~26 minute compile where a hit costs 2m21s -- eight times over + in a single run, because each Spark and Iceberg caller builds its own copy. + + The cargo caches already carry `if: github.ref == 'refs/heads/main'` on + their save. This holds every other large cache to the same rule, and + rejects the bare `actions/cache@vN` form for them outright, since its save + runs in an implicit post step that no `if:` can reach. + """ + failures = [] + sources = sorted(WORKFLOWS.glob("*.yml")) + sorted(WORKFLOWS.glob("*.yaml")) + sources += sorted(Path(".github/actions").glob("*/action.yaml")) + sources += sorted(Path(".github/actions").glob("*/action.yml")) + + for source in sources: + lines = source.read_text(encoding="utf-8").splitlines() + for line_no, uses, step in _cache_steps(lines): + body = "\n".join(step) + if not any(path in body for path in LARGE_CACHE_PATHS): + continue + key = "" + for entry in step: + if entry.strip().startswith("key:"): + key = entry.split("key:", 1)[1].strip() + break + if any( + source.name == name and key.startswith(prefix) + for name, prefix in CACHE_SAVE_SCOPE_EXEMPT + ): + continue + where = f"{source}:{line_no}" + if CACHE_RW_USES.match(uses): + failures.append( + f"{where}: `{key}` caches a large path with " + f"`actions/cache@vN`, whose save runs in an implicit post " + f"step that no `if:` can reach. Split it into " + f"`actions/cache/restore` plus an `actions/cache/save` " + f"carrying `if: github.ref == 'refs/heads/main'`, or exempt " + f"the key in CACHE_SAVE_SCOPE_EXEMPT with the reason" + ) + elif not CACHE_MAIN_GUARD.search(body): + failures.append( + f"{where}: `{key}` saves a large path without " + f"`if: github.ref == 'refs/heads/main'`, so a pull request " + f"or merge-queue run writes an entry no later run can " + f"restore, evicting main's from the shared budget" + ) + + for failure in failures: + print(f"cache save scope: {failure}") + return not failures + + if __name__ == "__main__": ok = check_change_filters() ok = check_event_policy() and ok @@ -1016,6 +1142,7 @@ def check_nightly_base_fallback(): ok = check_cache_refresh_scope() and ok ok = check_nightly_scope() and ok ok = check_nightly_base_fallback() and ok + ok = check_cache_save_scope() and ok if not ok: sys.exit(1) print("CI config checks passed")