From 71e00056ad303f2d69e242b5b5115ec24ac84ba9 Mon Sep 17 00:00:00 2001 From: Zachary Ferguson Date: Tue, 10 Nov 2026 10:41:59 -0500 Subject: [PATCH 1/2] Prune older ccaches after each save Each push to main saved a new copy of every ccache, pushing the repository past its 10 GB cache limit. A shared composite action now keeps only the newest cache per key prefix on the current ref. Co-Authored-By: Claude Opus 5.5 --- .github/actions/prune-ccache/action.yml | 40 +++++++++++++++++++++++++ .github/workflows/continuous.yml | 11 +++++++ .github/workflows/coverage.yml | 11 +++++++ .github/workflows/cuda.yml | 11 +++++++ .github/workflows/docs.yml | 12 ++++++-- .github/workflows/odr-check.yml | 11 +++++++ .github/workflows/python.yml | 11 +++++++ 7 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 .github/actions/prune-ccache/action.yml diff --git a/.github/actions/prune-ccache/action.yml b/.github/actions/prune-ccache/action.yml new file mode 100644 index 000000000..97683e78b --- /dev/null +++ b/.github/actions/prune-ccache/action.yml @@ -0,0 +1,40 @@ +name: Prune ccache +description: > + Delete the older caches that share a key prefix on the current ref, keeping + only the newest. + +# Every run saves its ccache under a new key (the prefix plus the run ID), +# because cache entries are immutable. Restores only ever read the newest one, +# so the older copies just take up space until GitHub evicts them. Each push to +# main saves about 3 GB across all workflows, so without pruning a few pushes +# are enough to exceed the repository's 10 GB cache limit. +# +# We keep the newest cache by creation time instead of the one this run just +# saved. If two runs on the same ref finish at the same time, both then keep the +# same, newest entry instead of deleting each other's. +# +# The calling job needs `permissions: actions: write`. Pruning is best effort: +# a failed delete, or a read-only token on a pull request from a fork, only +# leaves an extra cache behind until GitHub evicts it. + +inputs: + prefix: + description: The cache key prefix, the same string the restore step passes as restore-keys. + required: true + +runs: + using: composite + steps: + - name: Delete older caches + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PREFIX: ${{ inputs.prefix }} + run: | + gh cache list --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF" \ + --key "$PREFIX" --limit 100 --json id,key,createdAt \ + --jq 'sort_by(.createdAt) | .[:-1][] | "\(.id) \(.key)"' | + while read -r id key; do + echo "Deleting $key" + gh cache delete "$id" --repo "$GITHUB_REPOSITORY" || true + done diff --git a/.github/workflows/continuous.yml b/.github/workflows/continuous.yml index ea67d9d57..ee798dd9c 100644 --- a/.github/workflows/continuous.yml +++ b/.github/workflows/continuous.yml @@ -23,6 +23,10 @@ jobs: Build: name: ${{ matrix.name }} (${{ matrix.config }}) runs-on: ${{ matrix.os }} + # actions: write lets the prune step delete older ccaches. + permissions: + contents: read + actions: write strategy: fail-fast: false matrix: @@ -156,5 +160,12 @@ jobs: path: ${{ env.CACHE_PATH }} key: ${{ runner.os }}-${{ matrix.config }}-ccache-${{ github.run_id }} + - name: Prune old ccaches + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} + continue-on-error: true + uses: ./.github/actions/prune-ccache + with: + prefix: ${{ runner.os }}-${{ matrix.config }}-ccache- + - name: Tests run: cd build; ctest --verbose -j ${{ steps.cpu-cores.outputs.count }} diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 38b8d6671..5c1ae9e51 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -20,6 +20,10 @@ jobs: Coverage: name: Code Coverage runs-on: ubuntu-latest + # actions: write lets the prune step delete older ccaches. + permissions: + contents: read + actions: write strategy: fail-fast: false steps: @@ -84,6 +88,13 @@ jobs: path: ${{ env.CACHE_PATH }} key: coverage-ccache-${{ github.run_id }} + - name: Prune old ccaches + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} + continue-on-error: true + uses: ./.github/actions/prune-ccache + with: + prefix: coverage-ccache- + - name: Run Coverage run: | cd build diff --git a/.github/workflows/cuda.yml b/.github/workflows/cuda.yml index 717ec8cbb..0a323c017 100644 --- a/.github/workflows/cuda.yml +++ b/.github/workflows/cuda.yml @@ -29,6 +29,10 @@ jobs: Build: name: ${{ matrix.name }} CUDA (${{ matrix.config }}) runs-on: ${{ matrix.os }} + # actions: write lets the prune step delete older ccaches. + permissions: + contents: read + actions: write strategy: fail-fast: false matrix: @@ -194,6 +198,13 @@ jobs: path: ${{ env.CACHE_PATH }} key: cuda-${{ runner.os }}-${{ matrix.config }}-ccache-${{ github.run_id }} + - name: Prune old ccaches + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} + continue-on-error: true + uses: ./.github/actions/prune-ccache + with: + prefix: cuda-${{ runner.os }}-${{ matrix.config }}-ccache- + # The GPU unit tests, minus the CCD and STQ ones on the cloth and puffer # ball meshes, which take several minutes each on a simulated GPU. - name: Tests diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b6aa3c1de..6e35556bb 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -11,10 +11,11 @@ jobs: Docs: runs-on: ubuntu-latest # The deploy step pushes the built site to the gh-pages branch, which needs - # write access. We scope it to this job so the repo default can stay - # read-only. + # write access, and the prune step deletes older ccaches. We scope both to + # this job so the repo default can stay read-only. permissions: contents: write + actions: write steps: - name: Checkout repository uses: actions/checkout@v6 @@ -130,6 +131,13 @@ jobs: path: ${{ env.CACHE_PATH }} key: docs-ccache-${{ github.run_id }} + - name: Prune old ccaches + if: ${{ !cancelled() }} + continue-on-error: true + uses: ./.github/actions/prune-ccache + with: + prefix: docs-ccache- + - name: Build docs run: | cd docs diff --git a/.github/workflows/odr-check.yml b/.github/workflows/odr-check.yml index 4d35b3d8c..485b3eb01 100644 --- a/.github/workflows/odr-check.yml +++ b/.github/workflows/odr-check.yml @@ -32,6 +32,10 @@ jobs: odr: name: LTO ODR Check runs-on: ubuntu-latest + # actions: write lets the prune step delete older ccaches. + permissions: + contents: read + actions: write steps: - name: Checkout repository @@ -93,6 +97,13 @@ jobs: path: ~/.cache/ccache key: ${{ runner.os }}-odr-ccache-${{ github.run_id }} + - name: Prune old ccaches + if: always() + continue-on-error: true + uses: ./.github/actions/prune-ccache + with: + prefix: ${{ runner.os }}-odr-ccache- + - name: Check for ODR violations run: | if grep -q "violates the C++ One Definition Rule" build.log; then diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index aa92b2b17..94944a22a 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -50,6 +50,10 @@ jobs: needs: Python-Versions name: ${{ matrix.name }} Python ${{ matrix.python-version }} runs-on: ${{ matrix.os }} + # actions: write lets the prune step delete older ccaches. + permissions: + contents: read + actions: write strategy: fail-fast: false matrix: @@ -145,6 +149,13 @@ jobs: path: ${{ env.CACHE_PATH }} key: ${{ runner.os }}-Python${{ matrix.python-version }}-ccache-${{ github.run_id }} + - name: Prune old ccaches + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} + continue-on-error: true + uses: ./.github/actions/prune-ccache + with: + prefix: ${{ runner.os }}-Python${{ matrix.python-version }}-ccache- + - name: Tests run: | pip install -r python/tests/requirements.txt From 45040942173201a8a21d84357975cff8a4e73e7b Mon Sep 17 00:00:00 2001 From: Zachary Ferguson Date: Tue, 10 Nov 2026 10:50:29 -0500 Subject: [PATCH 2/2] Move ccache pruning into its own workflow Only the prune job now holds actions: write. The build jobs, which run third-party actions and downloaded dependencies, keep the repository's read-only default token. Co-Authored-By: Claude Opus 5.5 --- .github/actions/prune-ccache/action.yml | 40 ----------------- .github/workflows/continuous.yml | 11 ----- .github/workflows/coverage.yml | 11 ----- .github/workflows/cuda.yml | 11 ----- .github/workflows/docs.yml | 12 +---- .github/workflows/odr-check.yml | 11 ----- .github/workflows/prune-caches.yml | 60 +++++++++++++++++++++++++ .github/workflows/python.yml | 11 ----- 8 files changed, 62 insertions(+), 105 deletions(-) delete mode 100644 .github/actions/prune-ccache/action.yml create mode 100644 .github/workflows/prune-caches.yml diff --git a/.github/actions/prune-ccache/action.yml b/.github/actions/prune-ccache/action.yml deleted file mode 100644 index 97683e78b..000000000 --- a/.github/actions/prune-ccache/action.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Prune ccache -description: > - Delete the older caches that share a key prefix on the current ref, keeping - only the newest. - -# Every run saves its ccache under a new key (the prefix plus the run ID), -# because cache entries are immutable. Restores only ever read the newest one, -# so the older copies just take up space until GitHub evicts them. Each push to -# main saves about 3 GB across all workflows, so without pruning a few pushes -# are enough to exceed the repository's 10 GB cache limit. -# -# We keep the newest cache by creation time instead of the one this run just -# saved. If two runs on the same ref finish at the same time, both then keep the -# same, newest entry instead of deleting each other's. -# -# The calling job needs `permissions: actions: write`. Pruning is best effort: -# a failed delete, or a read-only token on a pull request from a fork, only -# leaves an extra cache behind until GitHub evicts it. - -inputs: - prefix: - description: The cache key prefix, the same string the restore step passes as restore-keys. - required: true - -runs: - using: composite - steps: - - name: Delete older caches - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PREFIX: ${{ inputs.prefix }} - run: | - gh cache list --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF" \ - --key "$PREFIX" --limit 100 --json id,key,createdAt \ - --jq 'sort_by(.createdAt) | .[:-1][] | "\(.id) \(.key)"' | - while read -r id key; do - echo "Deleting $key" - gh cache delete "$id" --repo "$GITHUB_REPOSITORY" || true - done diff --git a/.github/workflows/continuous.yml b/.github/workflows/continuous.yml index ee798dd9c..ea67d9d57 100644 --- a/.github/workflows/continuous.yml +++ b/.github/workflows/continuous.yml @@ -23,10 +23,6 @@ jobs: Build: name: ${{ matrix.name }} (${{ matrix.config }}) runs-on: ${{ matrix.os }} - # actions: write lets the prune step delete older ccaches. - permissions: - contents: read - actions: write strategy: fail-fast: false matrix: @@ -160,12 +156,5 @@ jobs: path: ${{ env.CACHE_PATH }} key: ${{ runner.os }}-${{ matrix.config }}-ccache-${{ github.run_id }} - - name: Prune old ccaches - if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} - continue-on-error: true - uses: ./.github/actions/prune-ccache - with: - prefix: ${{ runner.os }}-${{ matrix.config }}-ccache- - - name: Tests run: cd build; ctest --verbose -j ${{ steps.cpu-cores.outputs.count }} diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 5c1ae9e51..38b8d6671 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -20,10 +20,6 @@ jobs: Coverage: name: Code Coverage runs-on: ubuntu-latest - # actions: write lets the prune step delete older ccaches. - permissions: - contents: read - actions: write strategy: fail-fast: false steps: @@ -88,13 +84,6 @@ jobs: path: ${{ env.CACHE_PATH }} key: coverage-ccache-${{ github.run_id }} - - name: Prune old ccaches - if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} - continue-on-error: true - uses: ./.github/actions/prune-ccache - with: - prefix: coverage-ccache- - - name: Run Coverage run: | cd build diff --git a/.github/workflows/cuda.yml b/.github/workflows/cuda.yml index 0a323c017..717ec8cbb 100644 --- a/.github/workflows/cuda.yml +++ b/.github/workflows/cuda.yml @@ -29,10 +29,6 @@ jobs: Build: name: ${{ matrix.name }} CUDA (${{ matrix.config }}) runs-on: ${{ matrix.os }} - # actions: write lets the prune step delete older ccaches. - permissions: - contents: read - actions: write strategy: fail-fast: false matrix: @@ -198,13 +194,6 @@ jobs: path: ${{ env.CACHE_PATH }} key: cuda-${{ runner.os }}-${{ matrix.config }}-ccache-${{ github.run_id }} - - name: Prune old ccaches - if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} - continue-on-error: true - uses: ./.github/actions/prune-ccache - with: - prefix: cuda-${{ runner.os }}-${{ matrix.config }}-ccache- - # The GPU unit tests, minus the CCD and STQ ones on the cloth and puffer # ball meshes, which take several minutes each on a simulated GPU. - name: Tests diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6e35556bb..b6aa3c1de 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -11,11 +11,10 @@ jobs: Docs: runs-on: ubuntu-latest # The deploy step pushes the built site to the gh-pages branch, which needs - # write access, and the prune step deletes older ccaches. We scope both to - # this job so the repo default can stay read-only. + # write access. We scope it to this job so the repo default can stay + # read-only. permissions: contents: write - actions: write steps: - name: Checkout repository uses: actions/checkout@v6 @@ -131,13 +130,6 @@ jobs: path: ${{ env.CACHE_PATH }} key: docs-ccache-${{ github.run_id }} - - name: Prune old ccaches - if: ${{ !cancelled() }} - continue-on-error: true - uses: ./.github/actions/prune-ccache - with: - prefix: docs-ccache- - - name: Build docs run: | cd docs diff --git a/.github/workflows/odr-check.yml b/.github/workflows/odr-check.yml index 485b3eb01..4d35b3d8c 100644 --- a/.github/workflows/odr-check.yml +++ b/.github/workflows/odr-check.yml @@ -32,10 +32,6 @@ jobs: odr: name: LTO ODR Check runs-on: ubuntu-latest - # actions: write lets the prune step delete older ccaches. - permissions: - contents: read - actions: write steps: - name: Checkout repository @@ -97,13 +93,6 @@ jobs: path: ~/.cache/ccache key: ${{ runner.os }}-odr-ccache-${{ github.run_id }} - - name: Prune old ccaches - if: always() - continue-on-error: true - uses: ./.github/actions/prune-ccache - with: - prefix: ${{ runner.os }}-odr-ccache- - - name: Check for ODR violations run: | if grep -q "violates the C++ One Definition Rule" build.log; then diff --git a/.github/workflows/prune-caches.yml b/.github/workflows/prune-caches.yml new file mode 100644 index 000000000..f4fbe3843 --- /dev/null +++ b/.github/workflows/prune-caches.yml @@ -0,0 +1,60 @@ +name: Prune Caches + +# Every CI run saves its ccache under a new key (a prefix plus the run ID), +# because cache entries are immutable. Restores only ever read the newest one, +# so older copies just take up space. Each push to main saves about 3 GB across +# all workflows, so without pruning a few pushes are enough to exceed the +# repository's 10 GB cache limit, at which point GitHub's eviction can also drop +# the caches we still need. +# +# We prune in this separate workflow instead of in the build jobs so that only +# this job holds `actions: write`. The build jobs run third-party actions and +# compile dependencies that CPM downloads, and none of that code should get a +# token that can delete caches, cancel runs, or dispatch other workflows. This +# job never checks out the repository and runs nothing but `gh`, so pull request +# code never sees its token, even when a fork's pull request triggers it. + +on: + # We prune as soon as any workflow that saves a ccache finishes. + workflow_run: + workflows: [Build, Python, Coverage, CUDA, Docs, ODR Check] + types: [completed] + # The daily run catches anything a missed or failed trigger left behind. + schedule: + - cron: '17 4 * * *' + workflow_dispatch: + +permissions: + actions: write + +# Every run prunes the whole repository, so we never need two at once. A burst +# of triggers collapses into one running and one pending run. +concurrency: + group: prune-caches + cancel-in-progress: false + +jobs: + prune: + name: Prune old ccaches + runs-on: ubuntu-latest + steps: + # We group the caches by ref and by key minus its trailing run ID, then + # delete all but the newest in each group. Keeping the newest by creation + # time, instead of a specific key, means a cache saved while this runs is + # never deleted. Caches whose keys don't end in -ccache- (such as + # PantheonSim's toolkit cache) are left alone. A failed delete only + # leaves an extra cache behind until the next run. + - name: Delete all but the newest ccache per key prefix and ref + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + gh cache list --repo "$REPO" --limit 1000 --json id,key,ref,createdAt \ + --jq '[.[] | select(.key | test("-ccache-[0-9]+$"))] + | group_by([.ref, (.key | sub("[0-9]+$"; ""))]) + | .[] | sort_by(.createdAt) | .[:-1][] + | "\(.id)\t\(.ref)\t\(.key)"' | + while IFS=$'\t' read -r id ref key; do + echo "Deleting $key ($ref)" + gh cache delete "$id" --repo "$REPO" || true + done diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 94944a22a..aa92b2b17 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -50,10 +50,6 @@ jobs: needs: Python-Versions name: ${{ matrix.name }} Python ${{ matrix.python-version }} runs-on: ${{ matrix.os }} - # actions: write lets the prune step delete older ccaches. - permissions: - contents: read - actions: write strategy: fail-fast: false matrix: @@ -149,13 +145,6 @@ jobs: path: ${{ env.CACHE_PATH }} key: ${{ runner.os }}-Python${{ matrix.python-version }}-ccache-${{ github.run_id }} - - name: Prune old ccaches - if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} - continue-on-error: true - uses: ./.github/actions/prune-ccache - with: - prefix: ${{ runner.os }}-Python${{ matrix.python-version }}-ccache- - - name: Tests run: | pip install -r python/tests/requirements.txt