diff --git a/.github/detray-traccc-filters.yml b/.github/detray-traccc-filters.yml new file mode 100644 index 00000000000..b2c9c6e4783 --- /dev/null +++ b/.github/detray-traccc-filters.yml @@ -0,0 +1,38 @@ +# Merge-group filters: include shared build inputs as well as the package. +# Keep these conservative when the standalone builds gain new dependencies. +# Keep the workflow push/pull_request paths and .merge-sentinel.yml in sync. +detray: + - 'Detray/**' + # Standalone Detray uses ACTS' header compilation and codegen modules. + - 'cmake/**' + - 'codegen/**' + - 'thirdparty/**' + - 'CMakeLists.txt' + - 'CMakePresets.json' + - 'CI/**' + - '.github/actions/**' + - '.github/workflows/detray.yml' + - '.github/detray-traccc-filters.yml' + +traccc: + - 'Traccc/**' + - 'Detray/**' + # Traccc/extern/acts builds the colocated ACTS tree, including Core and + # plugins. Keep the source roots conservative as its presets evolve. + - 'Core/**' + - 'Plugins/**' + - 'Fatras/**' + - 'Alignment/**' + - 'Examples/**' + - 'Tests/**' + - 'Python/**' + - 'cmake/**' + - 'codegen/**' + - 'thirdparty/**' + - 'CMakeLists.txt' + - 'CMakePresets.json' + - 'version_number' + - 'CI/**' + - '.github/actions/**' + - '.github/workflows/traccc.yml' + - '.github/detray-traccc-filters.yml' diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index 3a93746a9b1..8a9a4ce837d 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -50,6 +50,9 @@ jobs: packages: read container: registry.cern.ch/ghcr.io/acts-project/ubuntu2604:90 env: + # The FPE build evicted objects during compilation at 500 MB. Cache + # retention removes superseded main archives to make room for this. + CCACHE_MAXSIZE: 1G INSTALL_DIR: ${{ github.workspace }}/install ACTS_LOG_FAILURE_THRESHOLD: WARNING diff --git a/.github/workflows/ccache-retention.yml b/.github/workflows/ccache-retention.yml new file mode 100644 index 00000000000..7c4be857eb6 --- /dev/null +++ b/.github/workflows/ccache-retention.yml @@ -0,0 +1,48 @@ +name: Compiler cache retention + +on: + # Only trusted main runs can trigger deletion; no PR artifacts or source + # are consumed. Checkout below always uses main, not the triggering SHA. + workflow_run: # zizmor: ignore[dangerous-triggers] + workflows: [Builds, Analysis, Detray, PyPI] + branches: [main] + types: [completed] + workflow_dispatch: + inputs: + apply: + description: Delete superseded main compiler-cache archives + type: boolean + default: false + +permissions: {} + +concurrency: + group: ccache-retention + cancel-in-progress: false + +jobs: + prune: + if: >- + github.repository == 'acts-project/acts' && + ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || + (github.event.workflow_run.head_repository.full_name == github.repository && + (github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'schedule'))) + runs-on: ubuntu-latest + permissions: + contents: read + actions: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + persist-credentials: false + - name: Retain newest compiler caches + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + APPLY: ${{ github.event_name == 'workflow_run' || inputs.apply }} + run: | + args=() + if [[ "$APPLY" == "true" ]]; then + args+=(--apply) + fi + python3 CI/prune_ccache.py "${args[@]}" diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 60093dc8abb..b0b889e56c1 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -114,8 +114,9 @@ jobs: # The CI tooling's own self-tests, which otherwise have no feedback loop: # broken tooling fails in some other job that already paid for a container - # pull. Grouped because they are pure python and run in seconds; separate - # steps so a failure names the suite. test_public_api_surface.py stays in + # pull. Grouped because they are pure Python and run in seconds. + # Use explicit test paths to avoid collecting other CI tools. + # test_public_api_surface.py stays in # `api_surface` -- it skips its drift check without doxygen on PATH. self_tests: runs-on: ubuntu-slim @@ -126,12 +127,18 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.14' - - name: Dependency lockfile selection + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + enable-cache: false + - name: CI tooling self-tests run: > + uv run --no-project --no-build + --with-requirements CI/self_tests/requirements.txt + python -m pytest -q CI/dependencies/test_select_lockfile.py - - name: Deprecated-docs checker - run: > CI/public_api/test_check_deprecated_docs.py + CI/test_check_unused_files.py + CI/test_prune_ccache.py codegen_prebuilt: # Pre-generates the code the build would otherwise generate, then checks diff --git a/.github/workflows/detray.yml b/.github/workflows/detray.yml index c55fb1cbbfc..1801edeb692 100644 --- a/.github/workflows/detray.yml +++ b/.github/workflows/detray.yml @@ -10,19 +10,35 @@ on: - 'releases' - 'release/**' - 'gh-readonly-queue/**' + # Keep in sync with .github/detray-traccc-filters.yml and .merge-sentinel.yml. paths: - "Detray/**" + - "cmake/**" + - "codegen/**" + - "thirdparty/**" + - "CMakeLists.txt" + - "CMakePresets.json" + - "CI/**" + - ".github/actions/**" - ".github/workflows/detray.yml" + - ".github/detray-traccc-filters.yml" pull_request: branches: - main + # Keep in sync with .github/detray-traccc-filters.yml and .merge-sentinel.yml. paths: - "Detray/**" + - "cmake/**" + - "codegen/**" + - "thirdparty/**" + - "CMakeLists.txt" + - "CMakePresets.json" + - "CI/**" + - ".github/actions/**" - ".github/workflows/detray.yml" - # merge_group does not support path filters, so this runs on every queue - # entry targeting main, not just ones touching Detray/. Only required via - # merge-sentinel's "Detray / *" pattern, which is itself path-gated, so a - # non-Detray entry running this wastes compute but doesn't block merging. + - ".github/detray-traccc-filters.yml" + # The changes job applies path filtering to merge groups, which do not + # support the workflow-level path filters above. merge_group: types: [checks_requested] @@ -45,9 +61,34 @@ env: # All the different build/test jobs. jobs: + # Like Builds/changes, gate jobs rather than suppressing check reporting. + # PR/push triggers already filter paths; only merge groups need a checkout + # and a comparison against the base SHA carried by the event. + changes: + if: github.event_name == 'merge_group' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + relevant: ${{ steps.filter.outputs.detray }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + id: filter + with: + base: ${{ github.event.merge_group.base_sha }} + ref: ${{ github.event.merge_group.head_sha }} + filters: .github/detray-traccc-filters.yml # Native build jobs. native: + needs: changes + # !cancelled() allows PR/push jobs through when changes is skipped. + if: >- + !cancelled() && (github.event_name != 'merge_group' || + (needs.changes.result == 'success' && needs.changes.outputs.relevant == 'true')) # The different build modes to test. strategy: @@ -93,6 +134,11 @@ jobs: # Containerised build jobs. host-container: + needs: changes + # !cancelled() allows PR/push jobs through when changes is skipped. + if: >- + !cancelled() && (github.event_name != 'merge_group' || + (needs.changes.result == 'success' && needs.changes.outputs.relevant == 'true')) # The different build modes to test. strategy: @@ -192,6 +238,11 @@ jobs: # Containerised build jobs. Debug compile checks only -- nothing here is # executed. CUDA has its own build/test pair below. device-container: + needs: changes + # !cancelled() allows PR/push jobs through when changes is skipped. + if: >- + !cancelled() && (github.event_name != 'merge_group' || + (needs.changes.result == 'success' && needs.changes.outputs.relevant == 'true')) # The different build modes to test. strategy: @@ -291,6 +342,11 @@ jobs: # build down to what the GPU job actually runs. # ───────────────────────────────────────────────────────────────────── cuda: + needs: changes + # !cancelled() allows PR/push jobs through when changes is skipped. + if: >- + !cancelled() && (github.event_name != 'merge_group' || + (needs.changes.result == 'success' && needs.changes.outputs.relevant == 'true')) name: "cuda (${{ matrix.SCALAR_TYPE }})" runs-on: [self-hosted, linux, x64, husk-size-standard] permissions: @@ -405,6 +461,7 @@ jobs: # ───────────────────────────────────────────────────────────────────── cuda-test: needs: [cuda] + if: ${{ !cancelled() && needs.cuda.result == 'success' }} runs-on: [self-hosted, linux, x64, gpu-nvidia] permissions: contents: read diff --git a/.github/workflows/traccc.yml b/.github/workflows/traccc.yml index dd4d64fac3c..6354be055bf 100644 --- a/.github/workflows/traccc.yml +++ b/.github/workflows/traccc.yml @@ -16,22 +16,53 @@ on: - 'releases' - 'release/**' - 'gh-readonly-queue/**' + # Keep in sync with .github/detray-traccc-filters.yml and .merge-sentinel.yml. paths: - "Traccc/**" - "Detray/**" + - "Core/**" + - "Plugins/**" + - "Fatras/**" + - "Alignment/**" + - "Examples/**" + - "Tests/**" + - "Python/**" + - "cmake/**" + - "codegen/**" + - "thirdparty/**" + - "CMakeLists.txt" + - "CMakePresets.json" + - "version_number" + - "CI/**" + - ".github/actions/**" - ".github/workflows/traccc.yml" + - ".github/detray-traccc-filters.yml" pull_request: branches: - main + # Keep in sync with .github/detray-traccc-filters.yml and .merge-sentinel.yml. paths: - "Traccc/**" - "Detray/**" + - "Core/**" + - "Plugins/**" + - "Fatras/**" + - "Alignment/**" + - "Examples/**" + - "Tests/**" + - "Python/**" + - "cmake/**" + - "codegen/**" + - "thirdparty/**" + - "CMakeLists.txt" + - "CMakePresets.json" + - "version_number" + - "CI/**" + - ".github/actions/**" - ".github/workflows/traccc.yml" - # merge_group does not support path filters, so this runs on every queue - # entry targeting main, not just ones touching Traccc/ or Detray/. Only - # required via merge-sentinel's "Traccc / *" pattern, which is itself - # path-gated, so a non-Traccc entry running this wastes compute but doesn't - # block merging. + - ".github/detray-traccc-filters.yml" + # The changes job applies path filtering to merge groups, which do not + # support the workflow-level path filters above. merge_group: types: [checks_requested] @@ -42,7 +73,33 @@ concurrency: cancel-in-progress: ${{ github.event_name != 'merge_group' }} jobs: + # Like Builds/changes, gate jobs rather than suppressing check reporting. + # PR/push triggers already filter paths; only merge groups need a checkout + # and a comparison against the base SHA carried by the event. + changes: + if: github.event_name == 'merge_group' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + relevant: ${{ steps.filter.outputs.traccc }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + id: filter + with: + base: ${{ github.event.merge_group.base_sha }} + ref: ${{ github.event.merge_group.head_sha }} + filters: .github/detray-traccc-filters.yml + containers: + needs: changes + # !cancelled() allows PR/push jobs through when changes is skipped. + if: >- + !cancelled() && (github.event_name != 'merge_group' || + (needs.changes.result == 'success' && needs.changes.outputs.relevant == 'true')) name: ${{ matrix.platform.name }}-${{ matrix.build }} runs-on: ubuntu-latest container: ${{ matrix.platform.container }} @@ -245,6 +302,7 @@ jobs: # ───────────────────────────────────────────────────────────────────── cuda-test: needs: [containers] + if: ${{ !cancelled() && needs.containers.result == 'success' }} runs-on: [self-hosted, linux, x64, gpu-nvidia] permissions: contents: read diff --git a/.merge-sentinel.yml b/.merge-sentinel.yml index de04f303ae1..a3bc6a9a653 100644 --- a/.merge-sentinel.yml +++ b/.merge-sentinel.yml @@ -37,25 +37,67 @@ rules: - "main" - "develop/*" paths: - # Both spellings: the glob engine here is undocumented, so which one is - # recursive cannot be assumed. + # Keep in sync with .github/detray-traccc-filters.yml and the workflow. + # Include direct and nested files for the sentinel glob matcher. - "Detray/*" - "Detray/**/*" + - "cmake/*" + - "cmake/**/*" + - "codegen/*" + - "codegen/**/*" + - "thirdparty/*" + - "thirdparty/**/*" + - "CMakeLists.txt" + - "CMakePresets.json" + - "CI/*" + - "CI/**/*" + - ".github/actions/*" + - ".github/actions/**/*" - ".github/workflows/detray.yml" + - ".github/detray-traccc-filters.yml" required_pattern: - "Detray / *" - # Without this the Traccc jobs run on a queue entry but do not gate it: - # pending only blocks when required, and they take ~25 minutes, so the - # aggregate would go green long before they report. A Detray-only change - # still runs them (traccc.yml triggers on Detray/** too) and a *failure* - # fails the gate whether required or not -- only the wait is missing there. + # Require Traccc for all inputs that trigger it, including Detray and shared + # ACTS inputs, so the gate waits for these jobs before allowing a merge. - branch_filter: - "main" - "develop/*" paths: + # Keep in sync with .github/detray-traccc-filters.yml and the workflow. + # Include direct and nested files for the sentinel glob matcher. - "Traccc/*" - "Traccc/**/*" + - "Detray/*" + - "Detray/**/*" + - "Core/*" + - "Core/**/*" + - "Plugins/*" + - "Plugins/**/*" + - "Fatras/*" + - "Fatras/**/*" + - "Alignment/*" + - "Alignment/**/*" + - "Examples/*" + - "Examples/**/*" + - "Tests/*" + - "Tests/**/*" + - "Python/*" + - "Python/**/*" + - "cmake/*" + - "cmake/**/*" + - "codegen/*" + - "codegen/**/*" + - "thirdparty/*" + - "thirdparty/**/*" + - "CMakeLists.txt" + - "CMakePresets.json" + - "version_number" + - "CI/*" + - "CI/**/*" + - ".github/actions/*" + - ".github/actions/**/*" - ".github/workflows/traccc.yml" + - ".github/detray-traccc-filters.yml" required_pattern: - "Traccc / *" diff --git a/CI/check_unused_files.py b/CI/check_unused_files.py index ffc0219af45..d67193a067f 100755 --- a/CI/check_unused_files.py +++ b/CI/check_unused_files.py @@ -155,11 +155,12 @@ def keep(name): def file_can_be_removed(searchstring, scope): - cmd = "grep -IR '" + searchstring + "' " + " ".join(scope) - - p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE) - output, _ = p.communicate() - return output == b"" + # Only existence matters: stop at the first match instead of scanning the + # entire tree and collecting every matching line for each candidate. + result = subprocess.run(["grep", "-IRq", "--", searchstring, *scope]) + if result.returncode not in (0, 1): + result.check_returncode() + return result.returncode == 1 def count_files(path="."): diff --git a/CI/cibuildwheel.sh b/CI/cibuildwheel.sh index 3df5675f766..c1149800aef 100755 --- a/CI/cibuildwheel.sh +++ b/CI/cibuildwheel.sh @@ -22,7 +22,9 @@ export CIBW_SKIP="*-musllinux* *-manylinux_i686" SETUP_CMD="bash {package}/CI/dependencies/setup.sh -t v23.3.1 -d deps -e env.sh" export CIBW_BEFORE_ALL_LINUX="dnf install -y bc ccache && ${SETUP_CMD}" export CIBW_BEFORE_ALL_MACOS="brew install ninja ccache && ${SETUP_CMD}" -export CIBW_ENVIRONMENT_PASS="CI GITHUB_TOKEN" +# Linux wheels build in a container: the job's ccache ceiling must be passed +# explicitly or the container uses ccache's default (5 GB). +export CIBW_ENVIRONMENT_PASS="CI GITHUB_TOKEN CCACHE_MAXSIZE" export CIBW_BEFORE_BUILD="ccache -z" export CIBW_ENVIRONMENT_LINUX="CMAKE_PREFIX_PATH=\$PWD/deps/venv:\$PWD/deps/view CCACHE_DIR=/host${CCACHE_DIR} LD_LIBRARY_PATH=\$PWD/deps/view/lib64:\$PWD/deps/view/lib:\$PWD/deps/venv/lib64:\$PWD/deps/venv/lib" export CIBW_ENVIRONMENT_MACOS="CMAKE_PREFIX_PATH=\$PWD/deps/venv:\$PWD/deps/view CCACHE_DIR=${CCACHE_DIR} MACOSX_DEPLOYMENT_TARGET=26.0" diff --git a/CI/prune_ccache.py b/CI/prune_ccache.py new file mode 100644 index 00000000000..911f9f945b5 --- /dev/null +++ b/CI/prune_ccache.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Retain the newest main compiler cache per variant and archive format. + +Dry-run by default. Only SHA-suffixed ccache keys are eligible; an uploaded, +non-empty replacement must exist before an older archive can be deleted. +""" + +import argparse +from collections import defaultdict +import json +import os +from pathlib import Path +import re +import subprocess + +MAIN_REF = "refs/heads/main" +CACHE_KEY = re.compile(r"^(ccache-.+-r\d+(?:-.+)?)-[0-9a-f]{40}$") + + +def superseded_caches(caches): + """Return (replacement, obsolete archives) pairs, preserving cache versions.""" + groups = defaultdict(list) + for cache in caches: + match = CACHE_KEY.fullmatch(cache["key"]) + if ( + cache["ref"] == MAIN_REF + and match + and cache.get("version") + and cache["size_in_bytes"] > 0 + ): + groups[(match[1], cache["version"])].append(cache) + result = [] + for group in groups.values(): + ordered = sorted(group, key=lambda c: (c["created_at"], c["id"]), reverse=True) + if len(ordered) > 1: + result.append((ordered[0], ordered[1:])) + return result + + +def list_caches(repo, key="ccache-"): + # Fetch all pages before any deletion, so changing page boundaries cannot + # cause entries to be skipped. -X GET prevents -f switching to POST. + output = subprocess.check_output( + [ + "gh", + "api", + "--method", + "GET", + "--paginate", + "--slurp", + f"repos/{repo}/actions/caches", + "-f", + f"ref={MAIN_REF}", + "-f", + f"key={key}", + "-f", + "per_page=100", + ], + text=True, + ) + return [cache for page in json.loads(output) for cache in page["actions_caches"]] + + +def prune(repo, caches, apply=False): + total = 0 + for replacement, obsolete in superseded_caches(caches): + if apply: + # GitHub may have evicted our replacement since the initial listing. + # Recheck it before pruning this variant. A new upload is never in + # the deletion list, which contains only IDs from the initial read. + available = list_caches(repo, replacement["key"]) + if not any( + c["id"] == replacement["id"] and c["size_in_bytes"] > 0 + for c in available + ): + print(f"Keep older caches: replacement {replacement['id']} disappeared") + continue + print(f"Keep {replacement['id']}: {replacement['key']}") + for cache in obsolete: + print( + f"{'Delete' if apply else 'Would delete'} {cache['id']}: {cache['key']}" + ) + if apply: + subprocess.run( + [ + "gh", + "api", + "--method", + "DELETE", + f"repos/{repo}/actions/caches/{cache['id']}", + ], + check=True, + ) + total += cache["size_in_bytes"] + print(f"{'Removed' if apply else 'Reclaimable'} archive bytes: {total:,}") + return total + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY")) + parser.add_argument( + "--apply", action="store_true", help="Delete superseded archives" + ) + parser.add_argument( + "--snapshot", type=Path, help="Dry-run a saved cache API response" + ) + args = parser.parse_args() + if args.apply and args.snapshot: + parser.error("--apply requires a fresh API listing, not --snapshot") + if not args.snapshot and not args.repo: + parser.error("--repo or GITHUB_REPOSITORY is required") + caches = ( + json.loads(args.snapshot.read_text())["actions_caches"] + if args.snapshot + else list_caches(args.repo) + ) + prune(args.repo, caches, args.apply) + + +if __name__ == "__main__": + main() diff --git a/CI/self_tests/requirements.in b/CI/self_tests/requirements.in new file mode 100644 index 00000000000..e079f8a6038 --- /dev/null +++ b/CI/self_tests/requirements.in @@ -0,0 +1 @@ +pytest diff --git a/CI/self_tests/requirements.txt b/CI/self_tests/requirements.txt new file mode 100644 index 00000000000..d1c7ee9a050 --- /dev/null +++ b/CI/self_tests/requirements.txt @@ -0,0 +1,14 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile --universal --python-version 3.14 --no-build CI/self_tests/requirements.in -o CI/self_tests/requirements.txt +colorama==0.4.6 ; sys_platform == 'win32' + # via pytest +iniconfig==2.3.0 + # via pytest +packaging==26.3 + # via pytest +pluggy==1.6.0 + # via pytest +pygments==2.21.0 + # via pytest +pytest==9.1.1 + # via -r CI/self_tests/requirements.in diff --git a/CI/test_check_unused_files.py b/CI/test_check_unused_files.py new file mode 100644 index 00000000000..f23e63f6115 --- /dev/null +++ b/CI/test_check_unused_files.py @@ -0,0 +1,22 @@ +"""Exercise reference matching and search failures with real grep calls.""" + +import subprocess + +import pytest + +from check_unused_files import file_can_be_removed + + +def test_reference_matching(tmp_path): + (tmp_path / "references.txt").write_text( + "#include \nimport package.module\n" + ) + scope = [str(tmp_path)] + assert not file_can_be_removed("used.hpp", scope) + assert not file_can_be_removed(r"import .*module", scope) + assert file_can_be_removed("unused.hpp", scope) + + +def test_search_failure(tmp_path): + with pytest.raises(subprocess.CalledProcessError): + file_can_be_removed("unused.hpp", [str(tmp_path / "missing")]) diff --git a/CI/test_prune_ccache.py b/CI/test_prune_ccache.py new file mode 100644 index 00000000000..c0edfae4f7e --- /dev/null +++ b/CI/test_prune_ccache.py @@ -0,0 +1,62 @@ +"""Test cache selection and deletion without accessing GitHub.""" + +from unittest.mock import Mock, call + +import pytest + +import prune_ccache + + +def cache(i, variant="Linux-linux_ubuntu-r2", **changes): + result = { + "id": i, + "key": f"ccache-{variant}-{i:040x}", + "ref": "refs/heads/main", + "version": "format-a", + "size_in_bytes": 100, + "created_at": f"2026-09-{i:02d}T00:00:00Z", + } + return result | changes + + +def test_only_superseded_main_archives_of_same_variant_and_version(): + old, new = cache(1), cache(2) + protected = [ + cache(3, ref="refs/pull/123/merge"), + cache(4, ref="refs/heads/feature"), + cache(5, version="format-b"), + cache(6, variant="Linux-linux_ubuntu_extra-r2-clang22-23"), + cache(7, variant="macOS-macos-r2"), + cache(8, key="spack-r5-Linux"), + cache(9, key="ccache-Linux-linux_ubuntu-r2-not-a-sha"), + cache(10, version=""), + cache(11, variant="Linux-linux_ubuntu-r3"), + cache(12, size_in_bytes=0), + ] + assert prune_ccache.superseded_caches([new, *protected, old]) == [(new, [old])] + assert prune_ccache.superseded_caches([old, cache(2, size_in_bytes=0)]) == [] + + +@pytest.mark.parametrize( + "apply, replacement_present, deleted_ids", + [(False, True, []), (True, True, [1]), (True, False, [])], + ids=["dry-run", "replacement-present", "replacement-disappeared"], +) +def test_cache_deletion(monkeypatch, apply, replacement_present, deleted_ids): + # A concurrent upload must never be included in the deletion plan. + listing = Mock(return_value=[cache(2), cache(3)] if replacement_present else []) + delete = Mock() + monkeypatch.setattr(prune_ccache, "list_caches", listing) + monkeypatch.setattr(prune_ccache.subprocess, "run", delete) + + prune_ccache.prune("owner/repo", [cache(1), cache(2)], apply=apply) + + assert delete.call_args_list == [ + call( + ["gh", "api", "--method", "DELETE", f"repos/owner/repo/actions/caches/{i}"], + check=True, + ) + for i in deleted_ids + ] + if not apply: + listing.assert_not_called()