From 5d8175636912c5f6178e760da5cb5f792b91c71a Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Mon, 9 Nov 2026 13:53:59 +0000 Subject: [PATCH 1/8] Reuse CI caches across dependency changes and delete superseded ones A change of the opam files missed every OPAM cache, so CI rebuilt the environments from scratch (9 to 16 minutes on master today, on every push of a pull request that changes them), and left the old ones behind: the repository's caches exceed the 10 GB limit, so live ones get evicted. The environment and build state of the previous dependencies are now restored and updated, and master deletes superseded generations when it saves. The coverage build state no longer changes key with every compiler commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kbf3gKHFTuMrZosy3Nb5Qc Signed-off-by: Christoph Knittel --- .github/actions/delete-caches/action.yml | 34 +++++++++ .github/actions/setup-ocaml/action.yml | 92 ++++++++++++++---------- .github/workflows/ci.yml | 28 ++++---- .github/workflows/coverage.yml | 26 +++---- 4 files changed, 118 insertions(+), 62 deletions(-) create mode 100644 .github/actions/delete-caches/action.yml diff --git a/.github/actions/delete-caches/action.yml b/.github/actions/delete-caches/action.yml new file mode 100644 index 0000000000..a1bbf8be1b --- /dev/null +++ b/.github/actions/delete-caches/action.yml @@ -0,0 +1,34 @@ +name: Delete caches +description: >- + Delete this ref's caches whose keys start with a prefix, except one key. + A key that ends in a hash of the inputs leaves its earlier generations + behind when the inputs change, and they push live caches out of the + repository's cache storage. + +inputs: + prefix: + description: Key prefix of the caches to delete. + required: true + keep: + description: Key of a cache to keep. + required: false + default: "" + +runs: + using: composite + steps: + - name: Delete caches + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PREFIX: ${{ inputs.prefix }} + KEEP: ${{ inputs.keep }} + run: | + gh cache list --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF" \ + --key "$PREFIX" --limit 100 --json key --jq '.[].key' | + while IFS= read -r key; do + if [[ "$key" != "$KEEP" ]]; then + echo "Deleting $key" + gh cache delete "$key" --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF" + fi + done diff --git a/.github/actions/setup-ocaml/action.yml b/.github/actions/setup-ocaml/action.yml index 7d8fc79995..74c77d9089 100644 --- a/.github/actions/setup-ocaml/action.yml +++ b/.github/actions/setup-ocaml/action.yml @@ -81,10 +81,13 @@ runs: dependency_flags_key="${DEPENDENCY_FLAGS//--/}" dependency_flags_key="${dependency_flags_key// /_}" dependency_hash="${{ hashFiles('*.opam') }}" - key="$CACHE_PREFIX-$OS-3.9.0-$COMPILER-$dependency_flags_key-$dependency_hash" - echo "value=${key//,/-}" >> "$GITHUB_OUTPUT" + prefix="$CACHE_PREFIX-$OS-3.9.0-$COMPILER-$dependency_flags_key-" + echo "prefix=${prefix//,/-}" >> "$GITHUB_OUTPUT" + echo "value=${prefix//,/-}$dependency_hash" >> "$GITHUB_OUTPUT" echo "setup-version=3.9.0" >> "$GITHUB_OUTPUT" + # When the dependencies changed, the environment of the previous ones is + # restored and updated, which takes minutes less than building it anew. - name: Restore OPAM environment id: cache uses: actions/cache/restore@v6 @@ -97,9 +100,10 @@ runs: C:\cygwin C:\.opam key: ${{ steps.cache-key.outputs.value }} + restore-keys: ${{ steps.cache-key.outputs.prefix }} - name: Use OCaml ${{ inputs.compiler }} - if: steps.cache.outputs.cache-hit != 'true' + if: steps.cache.outputs.cache-matched-key == '' uses: ocaml/setup-ocaml@v3.9.0 with: ocaml-compiler: ${{ inputs.compiler }} @@ -112,7 +116,7 @@ runs: cache: false - name: Get OPAM executable path - if: steps.cache.outputs.cache-hit != 'true' + if: steps.cache.outputs.cache-matched-key == '' uses: actions/github-script@v9 with: script: | @@ -123,40 +127,8 @@ runs: await fs.writeFile('.opam-path', opamPath, 'utf-8'); console.log('stored path to .opam-path'); - - name: Install OPAM dependencies - if: steps.cache.outputs.cache-hit != 'true' - shell: bash - env: - DEPENDENCY_FLAGS: ${{ inputs.dependency-flags }} - run: | - read -ra dependency_flags <<< "$DEPENDENCY_FLAGS" - if [[ "$RUNNER_OS" == "Windows" ]]; then - export AR=x86_64-w64-mingw32-ar - export RANLIB=x86_64-w64-mingw32-ranlib - export STRIP=x86_64-w64-mingw32-strip - export NM=x86_64-w64-mingw32-nm - export DLLTOOL=x86_64-w64-mingw32-dlltool - export OBJDUMP=x86_64-w64-mingw32-objdump - fi - opam install . "${dependency_flags[@]}" - - - name: Cache OPAM environment - # Caches created by pull_request runs are scoped to that PR's merge ref - # and cannot seed other PRs or branches, so only push runs save them. - if: steps.cache.outputs.cache-hit != 'true' && github.event_name == 'push' - uses: actions/cache/save@v6 - with: - path: | - ${{ runner.tool_cache }}/opam - ~/.opam - _opam - .opam-path - C:\cygwin - C:\.opam - key: ${{ steps.cache-key.outputs.value }} - - name: Use cached OPAM environment - if: steps.cache.outputs.cache-hit == 'true' + if: steps.cache.outputs.cache-matched-key != '' shell: bash run: | echo "OPAMCOLOR=always" >> "$GITHUB_ENV" @@ -190,3 +162,49 @@ runs: echo "CYGWIN=winsymlinks:native" >> "$GITHUB_ENV" echo "BASH_ENV=C:\\cygwin\\bash_env" >> "$GITHUB_ENV" fi + + # A restored environment of earlier dependencies may predate the + # packages they now need. + - name: Update OPAM repository + if: steps.cache.outputs.cache-matched-key != '' && steps.cache.outputs.cache-hit != 'true' + shell: bash + run: opam update --repositories + + - name: Install OPAM dependencies + if: steps.cache.outputs.cache-hit != 'true' + shell: bash + env: + DEPENDENCY_FLAGS: ${{ inputs.dependency-flags }} + run: | + read -ra dependency_flags <<< "$DEPENDENCY_FLAGS" + if [[ "$RUNNER_OS" == "Windows" ]]; then + export AR=x86_64-w64-mingw32-ar + export RANLIB=x86_64-w64-mingw32-ranlib + export STRIP=x86_64-w64-mingw32-strip + export NM=x86_64-w64-mingw32-nm + export DLLTOOL=x86_64-w64-mingw32-dlltool + export OBJDUMP=x86_64-w64-mingw32-objdump + fi + opam install . "${dependency_flags[@]}" + + - name: Cache OPAM environment + # Caches created by pull_request runs are scoped to that PR's merge ref + # and cannot seed other PRs or branches, so only push runs save them. + if: steps.cache.outputs.cache-hit != 'true' && github.event_name == 'push' + uses: actions/cache/save@v6 + with: + path: | + ${{ runner.tool_cache }}/opam + ~/.opam + _opam + .opam-path + C:\cygwin + C:\.opam + key: ${{ steps.cache-key.outputs.value }} + + - name: Delete superseded OPAM environments + if: steps.cache.outputs.cache-hit != 'true' && github.event_name == 'push' + uses: ./.github/actions/delete-caches + with: + prefix: ${{ steps.cache-key.outputs.prefix }} + keep: ${{ steps.cache-key.outputs.value }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 229bcecc52..9f61834bdd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,9 +104,12 @@ jobs: id: compiler-build-state-key shell: bash run: | - echo "value=compiler-build-state-v1-${{ matrix.os }}-${{ steps.setup_ocaml.outputs.setup_version }}-${{ matrix.ocaml_compiler }}-${{ hashFiles('*.opam') }}" \ - | sed 's/,/-/g' >> "$GITHUB_OUTPUT" + prefix="compiler-build-state-v1-${{ matrix.os }}-${{ steps.setup_ocaml.outputs.setup_version }}-${{ matrix.ocaml_compiler }}-" + echo "prefix=${prefix//,/-}" >> "$GITHUB_OUTPUT" + echo "value=${prefix//,/-}${{ hashFiles('*.opam') }}" >> "$GITHUB_OUTPUT" + # After a change of the dependencies, the state built with the previous + # ones still saves most of the build. - name: Restore compiler build state if: github.base_ref == 'master' || github.ref == 'refs/heads/master' id: compiler-build-state @@ -117,21 +120,18 @@ jobs: ~/.cache/dune _build key: ${{ steps.compiler-build-state-key.outputs.value }} + restore-keys: ${{ steps.compiler-build-state-key.outputs.prefix }} - name: Build compiler run: opam exec -- dune build --display quiet + # A cache can't be overwritten, so the state is deleted and saved again, + # together with the states of earlier dependencies. - name: Delete stable compiler build state if: github.event_name == 'push' && github.ref == 'refs/heads/master' - shell: bash - run: | - gh extension install actions/gh-actions-cache - gh actions-cache delete ${{ steps.compiler-build-state-key.outputs.value }} \ - -R ${{ github.repository }} \ - -B "$GITHUB_REF" \ - --confirm || echo "not exist" - env: - GH_TOKEN: ${{ github.token }} + uses: ./.github/actions/delete-caches + with: + prefix: ${{ steps.compiler-build-state-key.outputs.prefix }} - name: Save compiler build state if: github.event_name == 'push' && github.ref == 'refs/heads/master' @@ -461,8 +461,9 @@ jobs: - name: Compiler build state key id: compiler-build-state-key run: | - echo "value=compiler-build-state-v1-ubuntu-26.04-${{ steps.setup_ocaml.outputs.setup_version }}-$OCAML_COMPILER-${{ hashFiles('*.opam') }}" \ - | sed 's/,/-/g' >> "$GITHUB_OUTPUT" + prefix="compiler-build-state-v1-ubuntu-26.04-${{ steps.setup_ocaml.outputs.setup_version }}-$OCAML_COMPILER-" + echo "prefix=${prefix//,/-}" >> "$GITHUB_OUTPUT" + echo "value=${prefix//,/-}${{ hashFiles('*.opam') }}" >> "$GITHUB_OUTPUT" - name: Restore compiler build state uses: actions/cache/restore@v6 @@ -474,6 +475,7 @@ jobs: ~/.cache/dune _build key: ${{ steps.compiler-build-state-key.outputs.value }} + restore-keys: ${{ steps.compiler-build-state-key.outputs.prefix }} - name: Build the compiler run: opam exec -- dune build --display quiet diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 606a1b44c2..2b8a013f3c 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -49,12 +49,16 @@ jobs: # --- Coverage build cache -------------------------------------------- # The bisect_ppx-instrumented dune build is a separate artifact from - # the main CI build, so it gets its own key. Only restore/save on - # master pushes — PRs share the master cache to stay fast but won't - # poison it. + # the main CI build, so it gets its own key. Only save on master + # pushes — PRs share the master cache to stay fast but won't poison + # it. The key changes only with the dependencies: a key that changed + # with the compiler's sources would never match a pull request's. - name: Coverage build state key id: coverage-build-state-key - run: echo "value=coverage-build-state-v1-${{ env.OS }}-${{ steps.setup_ocaml.outputs.setup_version }}-${{ env.OCAML_COMPILER }}-${{ hashFiles('*.opam', 'compiler/**', 'dune-project') }}" >> $GITHUB_OUTPUT + run: | + prefix="coverage-build-state-v1-${{ env.OS }}-${{ steps.setup_ocaml.outputs.setup_version }}-${{ env.OCAML_COMPILER }}-" + echo "prefix=$prefix" >> "$GITHUB_OUTPUT" + echo "value=$prefix${{ hashFiles('*.opam') }}" >> "$GITHUB_OUTPUT" - name: Restore coverage build state if: github.base_ref == 'master' || github.ref == 'refs/heads/master' @@ -65,20 +69,18 @@ jobs: ~/.cache/dune _build key: ${{ steps.coverage-build-state-key.outputs.value }} + restore-keys: ${{ steps.coverage-build-state-key.outputs.prefix }} - name: Run coverage run: opam exec -- make coverage + # A cache can't be overwritten, so the state is deleted and saved again, + # together with the states of earlier dependencies. - name: Delete stale coverage build state if: github.event_name == 'push' && github.ref == 'refs/heads/master' - run: | - gh extension install actions/gh-actions-cache - gh actions-cache delete ${{ steps.coverage-build-state-key.outputs.value }} \ - -R ${{ github.repository }} \ - -B "$GITHUB_REF" \ - --confirm || echo "not exist" - env: - GH_TOKEN: ${{ github.token }} + uses: ./.github/actions/delete-caches + with: + prefix: ${{ steps.coverage-build-state-key.outputs.prefix }} - name: Save coverage build state if: github.event_name == 'push' && github.ref == 'refs/heads/master' From 014e1ea81234decbc0a0233014dea07ce0d4d08a Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Mon, 9 Nov 2026 14:16:51 +0000 Subject: [PATCH 2/8] Brace the prefix in the coverage build state key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `$prefix6d99…` named a variable that doesn't exist, so the key was empty and the coverage job failed to restore its build state. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kbf3gKHFTuMrZosy3Nb5Qc Signed-off-by: Christoph Knittel --- .github/workflows/coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 2b8a013f3c..102411e76f 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -58,7 +58,7 @@ jobs: run: | prefix="coverage-build-state-v1-${{ env.OS }}-${{ steps.setup_ocaml.outputs.setup_version }}-${{ env.OCAML_COMPILER }}-" echo "prefix=$prefix" >> "$GITHUB_OUTPUT" - echo "value=$prefix${{ hashFiles('*.opam') }}" >> "$GITHUB_OUTPUT" + echo "value=${prefix}${{ hashFiles('*.opam') }}" >> "$GITHUB_OUTPUT" - name: Restore coverage build state if: github.base_ref == 'master' || github.ref == 'refs/heads/master' From 4a1637f577dcb33425bdcf9407dc18eff62cb90e Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Mon, 9 Nov 2026 14:17:57 +0000 Subject: [PATCH 3/8] Don't let opam rebuild a restored switch for its system packages On Linux, opam took the packages that setup-linux-dependencies unpacks without dpkg for removed and recompiled all 64 packages of the restored switch (6 to 9 minutes); the fallback now skips system dependencies. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kbf3gKHFTuMrZosy3Nb5Qc Signed-off-by: Christoph Knittel --- .github/actions/setup-ocaml/action.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/actions/setup-ocaml/action.yml b/.github/actions/setup-ocaml/action.yml index 74c77d9089..dce329baa7 100644 --- a/.github/actions/setup-ocaml/action.yml +++ b/.github/actions/setup-ocaml/action.yml @@ -175,6 +175,10 @@ runs: shell: bash env: DEPENDENCY_FLAGS: ${{ inputs.dependency-flags }} + # In a restored environment, opam takes the system packages that + # setup-linux-dependencies unpacks without dpkg, and Binaryen, for + # removed, and would rebuild the whole switch. + OPAMNODEPEXTS: ${{ steps.cache.outputs.cache-matched-key != '' && '1' || '0' }} run: | read -ra dependency_flags <<< "$DEPENDENCY_FLAGS" if [[ "$RUNNER_OS" == "Windows" ]]; then From 2cf79a96b5132670b2fd95866e7658b631c5d34e Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Mon, 9 Nov 2026 19:57:00 +0000 Subject: [PATCH 4/8] Grant build-compiler the permission to delete caches Codex review: the job relied on the repository's default token permissions for the cache deletions of master pushes, which now fail instead of being ignored. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kbf3gKHFTuMrZosy3Nb5Qc Signed-off-by: Christoph Knittel --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f61834bdd..9fe7062bbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,9 @@ env: jobs: build-compiler: + permissions: + contents: read + actions: write # Master pushes delete superseded caches. strategy: fail-fast: false matrix: From d965a5ee998347ad6502cf5a59bf1e8801be4335 Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Tue, 10 Nov 2026 14:52:29 +0000 Subject: [PATCH 5/8] Restore earlier OPAM environments for pull requests only and stop pruning A restored environment is only ever added to, and master saved it under the new key: a pin dropped from the opam files, a removed dependency or a missed upgrade stayed in every later environment, and only a prefix bump recovered. Pushes now build a missing environment anew, and only pull requests restore an earlier one. The pruning of superseded caches is gone: GitHub evicts the least recently used caches first, which are those, and its errors could fail master builds. On a fallback, opam keeps the installed packages as they are and bypasses only the two system packages it misreads, so new system dependencies are still installed. Review: Why keep the build state's fallback on master? dune rebuilds what the new dependencies change, so a restored state can't go stale. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kbf3gKHFTuMrZosy3Nb5Qc Signed-off-by: Christoph Knittel --- .github/actions/delete-caches/action.yml | 34 --------------------- .github/actions/setup-ocaml/action.yml | 39 +++++++++++------------- .github/workflows/ci.yml | 17 ++++++----- .github/workflows/coverage.yml | 18 ++++++----- 4 files changed, 38 insertions(+), 70 deletions(-) delete mode 100644 .github/actions/delete-caches/action.yml diff --git a/.github/actions/delete-caches/action.yml b/.github/actions/delete-caches/action.yml deleted file mode 100644 index a1bbf8be1b..0000000000 --- a/.github/actions/delete-caches/action.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Delete caches -description: >- - Delete this ref's caches whose keys start with a prefix, except one key. - A key that ends in a hash of the inputs leaves its earlier generations - behind when the inputs change, and they push live caches out of the - repository's cache storage. - -inputs: - prefix: - description: Key prefix of the caches to delete. - required: true - keep: - description: Key of a cache to keep. - required: false - default: "" - -runs: - using: composite - steps: - - name: Delete caches - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PREFIX: ${{ inputs.prefix }} - KEEP: ${{ inputs.keep }} - run: | - gh cache list --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF" \ - --key "$PREFIX" --limit 100 --json key --jq '.[].key' | - while IFS= read -r key; do - if [[ "$key" != "$KEEP" ]]; then - echo "Deleting $key" - gh cache delete "$key" --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF" - fi - done diff --git a/.github/actions/setup-ocaml/action.yml b/.github/actions/setup-ocaml/action.yml index dce329baa7..d1830ed4c4 100644 --- a/.github/actions/setup-ocaml/action.yml +++ b/.github/actions/setup-ocaml/action.yml @@ -86,8 +86,11 @@ runs: echo "value=${prefix//,/-}$dependency_hash" >> "$GITHUB_OUTPUT" echo "setup-version=3.9.0" >> "$GITHUB_OUTPUT" - # When the dependencies changed, the environment of the previous ones is - # restored and updated, which takes minutes less than building it anew. + # When a pull request changes the dependencies, the environment of the + # previous ones is restored and updated, which takes minutes less than + # building it anew. Pushes always build a missing environment anew: what + # they save is resolved from scratch, so packages dropped from the opam + # files, pins and earlier upgrades don't accumulate in it. - name: Restore OPAM environment id: cache uses: actions/cache/restore@v6 @@ -100,7 +103,7 @@ runs: C:\cygwin C:\.opam key: ${{ steps.cache-key.outputs.value }} - restore-keys: ${{ steps.cache-key.outputs.prefix }} + restore-keys: ${{ github.event_name == 'pull_request' && steps.cache-key.outputs.prefix || '' }} - name: Use OCaml ${{ inputs.compiler }} if: steps.cache.outputs.cache-matched-key == '' @@ -163,24 +166,25 @@ runs: echo "BASH_ENV=C:\\cygwin\\bash_env" >> "$GITHUB_ENV" fi - # A restored environment of earlier dependencies may predate the - # packages they now need. - - name: Update OPAM repository - if: steps.cache.outputs.cache-matched-key != '' && steps.cache.outputs.cache-hit != 'true' - shell: bash - run: opam update --repositories - - name: Install OPAM dependencies if: steps.cache.outputs.cache-hit != 'true' shell: bash env: DEPENDENCY_FLAGS: ${{ inputs.dependency-flags }} - # In a restored environment, opam takes the system packages that - # setup-linux-dependencies unpacks without dpkg, and Binaryen, for - # removed, and would rebuild the whole switch. - OPAMNODEPEXTS: ${{ steps.cache.outputs.cache-matched-key != '' && '1' || '0' }} + RESTORED: ${{ steps.cache.outputs.cache-matched-key != '' }} run: | read -ra dependency_flags <<< "$DEPENDENCY_FLAGS" + if [[ "$RESTORED" == "true" ]]; then + # The restored environment may predate packages the dependencies + # now need. The update leaves the installed packages as they are, + # as on an exact cache hit. + opam update --repositories + opam reinstall --forget-pending + # setup-linux-dependencies unpacks musl-tools without dpkg, and + # Binaryen is installed by hand, so opam takes both for removed and + # would rebuild the whole switch. + opam option --global 'depext-bypass=["binaryen" "musl-tools"]' + fi if [[ "$RUNNER_OS" == "Windows" ]]; then export AR=x86_64-w64-mingw32-ar export RANLIB=x86_64-w64-mingw32-ranlib @@ -205,10 +209,3 @@ runs: C:\cygwin C:\.opam key: ${{ steps.cache-key.outputs.value }} - - - name: Delete superseded OPAM environments - if: steps.cache.outputs.cache-hit != 'true' && github.event_name == 'push' - uses: ./.github/actions/delete-caches - with: - prefix: ${{ steps.cache-key.outputs.prefix }} - keep: ${{ steps.cache-key.outputs.value }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fe7062bbf..585a92ae68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,9 +28,6 @@ env: jobs: build-compiler: - permissions: - contents: read - actions: write # Master pushes delete superseded caches. strategy: fail-fast: false matrix: @@ -128,13 +125,17 @@ jobs: - name: Build compiler run: opam exec -- dune build --display quiet - # A cache can't be overwritten, so the state is deleted and saved again, - # together with the states of earlier dependencies. - name: Delete stable compiler build state if: github.event_name == 'push' && github.ref == 'refs/heads/master' - uses: ./.github/actions/delete-caches - with: - prefix: ${{ steps.compiler-build-state-key.outputs.prefix }} + shell: bash + run: | + gh extension install actions/gh-actions-cache + gh actions-cache delete ${{ steps.compiler-build-state-key.outputs.value }} \ + -R ${{ github.repository }} \ + -B "$GITHUB_REF" \ + --confirm || echo "not exist" + env: + GH_TOKEN: ${{ github.token }} - name: Save compiler build state if: github.event_name == 'push' && github.ref == 'refs/heads/master' diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 102411e76f..0b6c71f500 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -51,8 +51,9 @@ jobs: # The bisect_ppx-instrumented dune build is a separate artifact from # the main CI build, so it gets its own key. Only save on master # pushes — PRs share the master cache to stay fast but won't poison - # it. The key changes only with the dependencies: a key that changed - # with the compiler's sources would never match a pull request's. + # it. The key changes only with the dependencies, so master keeps one + # state per set of dependencies, deleting it and saving it again, + # rather than adding a new one with every change of the compiler. - name: Coverage build state key id: coverage-build-state-key run: | @@ -74,13 +75,16 @@ jobs: - name: Run coverage run: opam exec -- make coverage - # A cache can't be overwritten, so the state is deleted and saved again, - # together with the states of earlier dependencies. - name: Delete stale coverage build state if: github.event_name == 'push' && github.ref == 'refs/heads/master' - uses: ./.github/actions/delete-caches - with: - prefix: ${{ steps.coverage-build-state-key.outputs.prefix }} + run: | + gh extension install actions/gh-actions-cache + gh actions-cache delete ${{ steps.coverage-build-state-key.outputs.value }} \ + -R ${{ github.repository }} \ + -B "$GITHUB_REF" \ + --confirm || echo "not exist" + env: + GH_TOKEN: ${{ github.token }} - name: Save coverage build state if: github.event_name == 'push' && github.ref == 'refs/heads/master' From 3b8e0af7b7a6d21a7ba06aee1f6de808689b40d7 Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Tue, 10 Nov 2026 18:06:58 +0000 Subject: [PATCH 6/8] Unpin restored environments and resolve one job from scratch Review fixes for the pull request fallback. Pins of earlier dependencies are dropped before the install, which pins what the opam files still pin; the OCaml 5.0 job never restores an earlier environment, so a dependency change still gets one fresh resolution before the merge; and Windows installs read the bash_env that ocaml/setup-ocaml writes. The depext bypass now applies to every install, and only environments resolved from scratch are saved. The build state keys no longer end in a hash of the opam files, so master keeps one state per job. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kbf3gKHFTuMrZosy3Nb5Qc Signed-off-by: Christoph Knittel --- .github/actions/setup-ocaml/action.yml | 35 +++++++++++++++++++++----- .github/workflows/ci.yml | 23 +++++++++-------- .github/workflows/coverage.yml | 16 ++++++------ 3 files changed, 50 insertions(+), 24 deletions(-) diff --git a/.github/actions/setup-ocaml/action.yml b/.github/actions/setup-ocaml/action.yml index d1830ed4c4..11834590b4 100644 --- a/.github/actions/setup-ocaml/action.yml +++ b/.github/actions/setup-ocaml/action.yml @@ -15,6 +15,13 @@ inputs: description: Flags passed to opam install after the package path. required: false default: --deps-only --with-test + restore-earlier: + description: >- + Whether a pull request may restore and update the environment of + earlier dependencies when its own is not cached. A job that never does + resolves every change of the dependencies from scratch, as master will. + required: false + default: "true" outputs: setup_version: description: Version of ocaml/setup-ocaml used by this action. @@ -103,7 +110,7 @@ runs: C:\cygwin C:\.opam key: ${{ steps.cache-key.outputs.value }} - restore-keys: ${{ github.event_name == 'pull_request' && steps.cache-key.outputs.prefix || '' }} + restore-keys: ${{ github.event_name == 'pull_request' && inputs.restore-earlier == 'true' && steps.cache-key.outputs.prefix || '' }} - name: Use OCaml ${{ inputs.compiler }} if: steps.cache.outputs.cache-matched-key == '' @@ -113,6 +120,8 @@ runs: # Use the HTTP repository to prevent intermittent CI setup failures: # the Git-backed default can race with detached Git maintenance while # OPAM scans it (https://github.com/ocaml/opam/issues/7031). + # A restored environment keeps its repositories, so a change here + # needs a new cache-prefix. opam-repositories: | default: https://opam.ocaml.org opam-pin: false @@ -174,16 +183,29 @@ runs: RESTORED: ${{ steps.cache.outputs.cache-matched-key != '' }} run: | read -ra dependency_flags <<< "$DEPENDENCY_FLAGS" + if [[ "$RUNNER_OS" != "Windows" ]]; then + # setup-linux-dependencies unpacks musl-tools without dpkg, and + # Binaryen is installed above, so opam needn't install either. + # Without this, a restored environment takes both for removed and + # rebuilds the whole switch. + opam option --global 'depext-bypass=["binaryen" "musl-tools"]' + fi if [[ "$RESTORED" == "true" ]]; then + # Pins of earlier dependencies would hide every other version of + # their packages; the install pins what the opam files still pin. + for package in $(opam pin list --short | tr -d '\r'); do + opam unpin --no-action "$package" + done # The restored environment may predate packages the dependencies # now need. The update leaves the installed packages as they are, # as on an exact cache hit. opam update --repositories opam reinstall --forget-pending - # setup-linux-dependencies unpacks musl-tools without dpkg, and - # Binaryen is installed by hand, so opam takes both for removed and - # would rebuild the whole switch. - opam option --global 'depext-bypass=["binaryen" "musl-tools"]' + if [[ "$RUNNER_OS" == "Windows" ]]; then + # Where ocaml/setup-ocaml points it; the cached environment's + # setting names a file that doesn't exist. + export BASH_ENV='C:\.opam\.cygwin\root\bash_env' + fi fi if [[ "$RUNNER_OS" == "Windows" ]]; then export AR=x86_64-w64-mingw32-ar @@ -198,7 +220,8 @@ runs: - name: Cache OPAM environment # Caches created by pull_request runs are scoped to that PR's merge ref # and cannot seed other PRs or branches, so only push runs save them. - if: steps.cache.outputs.cache-hit != 'true' && github.event_name == 'push' + # Only environments resolved from scratch are saved. + if: steps.cache.outputs.cache-matched-key == '' && github.event_name == 'push' uses: actions/cache/save@v6 with: path: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 585a92ae68..15310e30a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,17 +99,21 @@ jobs: compiler: ${{ matrix.ocaml_compiler }} os: ${{ matrix.os }} cache-prefix: opam-env-v9 + # This job resolves every change of the dependencies from scratch, + # as master will after the merge. + restore-earlier: ${{ !contains(matrix.ocaml_compiler, '5.0.0') }} + # The key doesn't change with the dependencies: dune rebuilds what they + # change. Master deletes the state and saves it again. - name: Compiler build state key id: compiler-build-state-key shell: bash run: | - prefix="compiler-build-state-v1-${{ matrix.os }}-${{ steps.setup_ocaml.outputs.setup_version }}-${{ matrix.ocaml_compiler }}-" - echo "prefix=${prefix//,/-}" >> "$GITHUB_OUTPUT" - echo "value=${prefix//,/-}${{ hashFiles('*.opam') }}" >> "$GITHUB_OUTPUT" + echo "value=compiler-build-state-v1-${{ matrix.os }}-${{ steps.setup_ocaml.outputs.setup_version }}-${{ matrix.ocaml_compiler }}" \ + | sed 's/,/-/g' >> "$GITHUB_OUTPUT" - # After a change of the dependencies, the state built with the previous - # ones still saves most of the build. + # The prefix also restores the states saved under the earlier keys, + # which ended in a hash of the opam files. - name: Restore compiler build state if: github.base_ref == 'master' || github.ref == 'refs/heads/master' id: compiler-build-state @@ -120,7 +124,7 @@ jobs: ~/.cache/dune _build key: ${{ steps.compiler-build-state-key.outputs.value }} - restore-keys: ${{ steps.compiler-build-state-key.outputs.prefix }} + restore-keys: ${{ steps.compiler-build-state-key.outputs.value }} - name: Build compiler run: opam exec -- dune build --display quiet @@ -465,9 +469,8 @@ jobs: - name: Compiler build state key id: compiler-build-state-key run: | - prefix="compiler-build-state-v1-ubuntu-26.04-${{ steps.setup_ocaml.outputs.setup_version }}-$OCAML_COMPILER-" - echo "prefix=${prefix//,/-}" >> "$GITHUB_OUTPUT" - echo "value=${prefix//,/-}${{ hashFiles('*.opam') }}" >> "$GITHUB_OUTPUT" + echo "value=compiler-build-state-v1-ubuntu-26.04-${{ steps.setup_ocaml.outputs.setup_version }}-$OCAML_COMPILER" \ + | sed 's/,/-/g' >> "$GITHUB_OUTPUT" - name: Restore compiler build state uses: actions/cache/restore@v6 @@ -479,7 +482,7 @@ jobs: ~/.cache/dune _build key: ${{ steps.compiler-build-state-key.outputs.value }} - restore-keys: ${{ steps.compiler-build-state-key.outputs.prefix }} + restore-keys: ${{ steps.compiler-build-state-key.outputs.value }} - name: Build the compiler run: opam exec -- dune build --display quiet diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 0b6c71f500..63e3a51510 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -51,16 +51,15 @@ jobs: # The bisect_ppx-instrumented dune build is a separate artifact from # the main CI build, so it gets its own key. Only save on master # pushes — PRs share the master cache to stay fast but won't poison - # it. The key changes only with the dependencies, so master keeps one - # state per set of dependencies, deleting it and saving it again, - # rather than adding a new one with every change of the compiler. + # it. The key changes with neither the compiler nor the dependencies: + # dune rebuilds what they change, and master deletes the state and + # saves it again, so it keeps one. - name: Coverage build state key id: coverage-build-state-key - run: | - prefix="coverage-build-state-v1-${{ env.OS }}-${{ steps.setup_ocaml.outputs.setup_version }}-${{ env.OCAML_COMPILER }}-" - echo "prefix=$prefix" >> "$GITHUB_OUTPUT" - echo "value=${prefix}${{ hashFiles('*.opam') }}" >> "$GITHUB_OUTPUT" + run: echo "value=coverage-build-state-v1-${{ env.OS }}-${{ steps.setup_ocaml.outputs.setup_version }}-${{ env.OCAML_COMPILER }}" >> "$GITHUB_OUTPUT" + # The prefix also restores the states saved under the earlier keys, + # which ended in a hash of the sources. - name: Restore coverage build state if: github.base_ref == 'master' || github.ref == 'refs/heads/master' id: coverage-build-state @@ -70,7 +69,8 @@ jobs: ~/.cache/dune _build key: ${{ steps.coverage-build-state-key.outputs.value }} - restore-keys: ${{ steps.coverage-build-state-key.outputs.prefix }} + restore-keys: ${{ steps.coverage-build-state-key.outputs.value }} + - name: Run coverage run: opam exec -- make coverage From 449b4bd0121e4b29d07c0d419da8ca2676ae7f45 Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Tue, 10 Nov 2026 14:52:36 +0000 Subject: [PATCH 7/8] DO NOT MERGE: change an opam file to test the cache fallback Dropped before merging; it makes this pull request's CI take the fallback path. Signed-off-by: Christoph Knittel --- tools.opam | 1 + 1 file changed, 1 insertion(+) diff --git a/tools.opam b/tools.opam index 8f751de508..f4c243af41 100644 --- a/tools.opam +++ b/tools.opam @@ -28,3 +28,4 @@ build: [ "@doc" {with-doc} ] ] +# Temporary: exercise the CI cache fallback From af15778cc29d9af90c3b76f959f7d1c49b301a6a Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Tue, 10 Nov 2026 18:26:18 +0000 Subject: [PATCH 8/8] Drop the temporary opam change It made this pull request's CI take the fallback path: the restored jobs set up OPAM in 35 to 61 seconds, the OCaml 5.0 job resolved from scratch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kbf3gKHFTuMrZosy3Nb5Qc Signed-off-by: Christoph Knittel --- tools.opam | 1 - 1 file changed, 1 deletion(-) diff --git a/tools.opam b/tools.opam index f4c243af41..8f751de508 100644 --- a/tools.opam +++ b/tools.opam @@ -28,4 +28,3 @@ build: [ "@doc" {with-doc} ] ] -# Temporary: exercise the CI cache fallback