Skip to content

Commit 81a2ded

Browse files
cknittclaude
andcommitted
Save OPAM environments of dependency changes and bound the CI caches
Why: A pull request that changed the opam files rebuilt every OPAM environment on each push (531 s on Linux x64 to 953 s on Windows), since only pushes to master save caches. The caches exceeded GitHub's 10 GB, so live ones were evicted: the coverage build state, keyed on a hash of compiler/**, saved a new 266 MB entry on nearly every master push, earlier generations stayed until GitHub's 7-day eviction, and the compiler build states grew with dune's shared cache. When an OPAM environment misses, a pull request that changes `*.opam` saves the one it built, in the cache scope of its merge ref, so its later pushes restore it. Each save deletes the earlier environments with the same key prefix on the same ref, and a new workflow deletes a closed pull request's caches. The coverage build state is gone, the compiler build states keep only `_build`, and the Linux system packages are installed with apt instead of unpacked from a cached tarball that dpkg didn't know about, so opam took them for missing. Review: Why not restore the environment of earlier dependencies and update it? The result would depend on what that environment held: pins, repository state, installed packages, depext status, global options. Environments are still only restored on an exact key, so each is the one a fresh build of its opam files produced. Review: Why do only pull requests that change the opam files save? Any other pull request would store a copy of master's environments, 3.3 GB each. Review: Was it tested? On this pull request: a temporary opam change built and saved all six environments, the next push restored them in 9-39 s, a second change replaced them, and reverting it restored master's again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kbf3gGPsF3fHZn9rBxA7Hm Signed-off-by: Christoph Knittel <ck@cca.io>
1 parent 7f416af commit 81a2ded

5 files changed

Lines changed: 112 additions & 181 deletions

File tree

Lines changed: 12 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,20 @@
11
name: Setup Linux dependencies
2-
description: Install and cache the system packages required to build ReScript.
2+
description: Install the system packages required to build ReScript.
33

44
runs:
55
using: composite
66
steps:
7-
# This is a small, repository-specific version of the filesystem cache used
8-
# by awalsh128/cache-apt-pkgs-action@v1.4.3. Restoring the installed files is
9-
# considerably faster than downloading and installing the .deb files again.
10-
- name: Resolve package versions
11-
id: packages
7+
# Installed with apt, so that dpkg knows them: opam checks system
8+
# packages with dpkg, and an earlier cache that unpacked these files
9+
# without dpkg made opam take them for missing. musl-tools is needed for
10+
# the static builds; bubblewrap and rsync for opam builds in a restored
11+
# environment, such as coverage's bisect_ppx pin; cmake for OPAM.
12+
# ocaml/setup-ocaml installs its own packages, such as the multilib ones,
13+
# when it builds an environment.
14+
- name: Install system dependencies
1215
shell: bash
13-
env:
14-
# Dependencies required by setup-ocaml, plus cmake for OPAM. The
15-
# multilib packages are only available on x64 runners.
16-
PACKAGES: >-
17-
bubblewrap musl-tools rsync cmake
18-
${{ runner.arch == 'X64' && 'g++-multilib gcc-multilib' || '' }}
19-
CACHE_VERSION: v5
2016
run: |
21-
set -euo pipefail
22-
23-
read -ra packages <<< "$PACKAGES"
24-
mapfile -t packages < <(printf '%s\n' "${packages[@]}" | sort -u)
25-
26-
candidate() {
27-
local output
28-
output="$(apt-cache policy "$1" 2>&1)" || {
29-
echo "apt-cache policy $1 failed with exit code $?:" >&2
30-
printf '%s\n' "$output" >&2
31-
return 1
32-
}
33-
awk '/Candidate:/ { print $2; exit }' <<< "$output"
34-
}
35-
36-
# A runner image may ship without package lists, in which case no
37-
# candidate resolves until the lists are fetched.
38-
updated=false
39-
resolved_packages=()
40-
for package in "${packages[@]}"; do
41-
version="$(candidate "$package")" || version=""
42-
if [[ ( -z "$version" || "$version" == "(none)" ) && "$updated" == false ]]; then
43-
sudo apt-get update
44-
updated=true
45-
version="$(candidate "$package")" || version=""
46-
fi
47-
if [[ -z "$version" || "$version" == "(none)" ]]; then
48-
echo "Unable to resolve an APT candidate for $package" >&2
49-
exit 1
50-
fi
51-
resolved_packages+=("$package=$version")
52-
done
53-
54-
cache_key="$({
55-
printf '%s\n' "$CACHE_VERSION"
56-
(. /etc/os-release; printf '%s\n' "$ID" "$VERSION_ID")
57-
dpkg --print-architecture
58-
printf '%s\n' "${resolved_packages[@]}"
59-
} | sha256sum | cut -d' ' -f1)"
60-
61-
cache_dir="$RUNNER_TEMP/cache-apt-pkgs"
62-
mkdir -p "$cache_dir"
63-
printf '%s\n' "${resolved_packages[@]}" > "$cache_dir/requested-packages.txt"
64-
echo "cache-dir=$cache_dir" >> "$GITHUB_OUTPUT"
65-
echo "cache-key=$cache_key" >> "$GITHUB_OUTPUT"
66-
67-
- name: Restore system dependencies
68-
id: cache
69-
uses: actions/cache/restore@v6
70-
with:
71-
path: ${{ steps.packages.outputs.cache-dir }}
72-
key: cache-apt-pkgs-${{ runner.os }}-${{ steps.packages.outputs.cache-key }}
73-
74-
- name: Restore cached system dependencies
75-
if: steps.cache.outputs.cache-hit == 'true'
76-
shell: bash
77-
env:
78-
CACHE_DIR: ${{ steps.packages.outputs.cache-dir }}
79-
run: sudo tar -xf "$CACHE_DIR/packages.tar" -C /
80-
81-
- name: Install and cache system dependencies
82-
if: steps.cache.outputs.cache-hit != 'true'
83-
shell: bash
84-
env:
85-
CACHE_DIR: ${{ steps.packages.outputs.cache-dir }}
86-
run: |
87-
set -euo pipefail
88-
89-
sudo apt-get update
90-
mapfile -t packages < <(cut -d= -f1 "$CACHE_DIR/requested-packages.txt")
91-
sudo DEBIAN_FRONTEND=noninteractive apt-get install --yes \
17+
sudo apt-get update -qq
18+
sudo DEBIAN_FRONTEND=noninteractive apt-get install --yes -qq \
9219
--no-install-recommends --no-upgrade \
93-
"${packages[@]}" \
94-
| tee "$CACHE_DIR/install.log"
95-
96-
sed -nE 's/^Unpacking ([^ :]+)(:[^ ]+)? .*\(([^ )]+).*/\1\2/p' \
97-
"$CACHE_DIR/install.log" \
98-
| sort -u \
99-
> "$CACHE_DIR/installed-packages.txt"
100-
101-
if [[ -s "$CACHE_DIR/installed-packages.txt" ]]; then
102-
while IFS= read -r package; do
103-
dpkg-query --listfiles "$package"
104-
done < "$CACHE_DIR/installed-packages.txt" \
105-
| sort -u \
106-
| while IFS= read -r path; do
107-
if [[ -f "$path" || -L "$path" ]]; then
108-
printf '%s\n' "${path#/}"
109-
fi
110-
done \
111-
| sudo tar -cf "$CACHE_DIR/packages.tar" -C / -T -
112-
else
113-
# A newer runner image may already contain every requested package.
114-
sudo tar -cf "$CACHE_DIR/packages.tar" -T /dev/null
115-
fi
116-
117-
- name: Save system dependencies
118-
if: steps.cache.outputs.cache-hit != 'true'
119-
uses: actions/cache/save@v6
120-
with:
121-
path: ${{ steps.packages.outputs.cache-dir }}
122-
key: ${{ steps.cache.outputs.cache-primary-key }}
123-
124-
- name: Clean up system dependency cache
125-
if: always()
126-
shell: bash
127-
env:
128-
CACHE_DIR: ${{ steps.packages.outputs.cache-dir }}
129-
run: rm -rf "$CACHE_DIR"
20+
bubblewrap musl-tools rsync cmake

‎.github/actions/setup-ocaml/action.yml‎

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
name: Setup OCaml
22
description: Restore or install the repository OPAM environment.
33

4+
# An environment is only ever used as it was built: restored on an exact key,
5+
# or built from scratch, never restored from other dependencies and updated.
6+
# Pushes save what they build. A pull request that changes the opam files
7+
# saves its environment too, in its own cache scope, so that only its first
8+
# push per change builds from scratch. Each save deletes the earlier
9+
# generations of the same key prefix on the same ref.
10+
411
inputs:
512
compiler:
613
description: OCaml compiler switch specification.
@@ -81,8 +88,9 @@ runs:
8188
dependency_flags_key="${DEPENDENCY_FLAGS//--/}"
8289
dependency_flags_key="${dependency_flags_key// /_}"
8390
dependency_hash="${{ hashFiles('*.opam') }}"
84-
key="$CACHE_PREFIX-$OS-3.9.0-$COMPILER-$dependency_flags_key-$dependency_hash"
85-
echo "value=${key//,/-}" >> "$GITHUB_OUTPUT"
91+
prefix="$CACHE_PREFIX-$OS-3.9.0-$COMPILER-$dependency_flags_key-"
92+
echo "prefix=${prefix//,/-}" >> "$GITHUB_OUTPUT"
93+
echo "value=${prefix//,/-}$dependency_hash" >> "$GITHUB_OUTPUT"
8694
echo "setup-version=3.9.0" >> "$GITHUB_OUTPUT"
8795
8896
- name: Restore OPAM environment
@@ -140,10 +148,29 @@ runs:
140148
fi
141149
opam install . "${dependency_flags[@]}"
142150
151+
# A pull request that changes the dependencies saves its environment, so
152+
# that its next pushes find it. Caches saved by pull_request runs are
153+
# scoped to the pull request's merge ref, so no other branch sees them.
154+
# Other pull requests don't save: each would store a copy of master's.
155+
- name: Check whether the pull request changes the dependencies
156+
id: dependencies
157+
if: steps.cache.outputs.cache-hit != 'true' && github.event_name == 'pull_request'
158+
shell: bash
159+
env:
160+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
161+
run: |
162+
if git fetch --quiet --depth=1 origin "$BASE_SHA" &&
163+
! git diff --quiet FETCH_HEAD HEAD -- '*.opam'; then
164+
echo "changed=true" >> "$GITHUB_OUTPUT"
165+
else
166+
echo "changed=false" >> "$GITHUB_OUTPUT"
167+
fi
168+
143169
- name: Cache OPAM environment
144-
# Caches created by pull_request runs are scoped to that PR's merge ref
145-
# and cannot seed other PRs or branches, so only push runs save them.
146-
if: steps.cache.outputs.cache-hit != 'true' && github.event_name == 'push'
170+
id: save
171+
if: >-
172+
steps.cache.outputs.cache-hit != 'true' &&
173+
(github.event_name == 'push' || steps.dependencies.outputs.changed == 'true')
147174
uses: actions/cache/save@v6
148175
with:
149176
path: |
@@ -155,6 +182,26 @@ runs:
155182
C:\.opam
156183
key: ${{ steps.cache-key.outputs.value }}
157184

185+
# Keeps one environment per key prefix and ref. Needs actions: write;
186+
# without it, the earlier generations stay until GitHub evicts them.
187+
- name: Delete earlier OPAM environments
188+
if: steps.save.outcome == 'success'
189+
shell: bash
190+
env:
191+
GH_TOKEN: ${{ github.token }}
192+
PREFIX: ${{ steps.cache-key.outputs.prefix }}
193+
KEEP: ${{ steps.cache-key.outputs.value }}
194+
run: |
195+
gh api --paginate --method GET "repos/$GITHUB_REPOSITORY/actions/caches" \
196+
-f key="$PREFIX" -f ref="$GITHUB_REF" \
197+
--jq ".actions_caches[] | select(.key != \"$KEEP\") | .id" |
198+
while IFS= read -r id; do
199+
echo "Deleting cache $id"
200+
gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/caches/$id" ||
201+
echo "::warning::Couldn't delete cache $id."
202+
done ||
203+
echo "::warning::Couldn't list the earlier OPAM environments."
204+
158205
- name: Use cached OPAM environment
159206
if: steps.cache.outputs.cache-hit == 'true'
160207
shell: bash
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: Cache Cleanup
2+
3+
# Deletes the caches of a closed pull request: they are scoped to its merge
4+
# ref, so nothing can use them any more, but they count towards the
5+
# repository's cache storage until GitHub evicts them. The job runs no code
6+
# of the pull request.
7+
8+
on:
9+
pull_request_target:
10+
types: [closed]
11+
workflow_dispatch:
12+
inputs:
13+
pr-number:
14+
description: The pull request whose caches to delete.
15+
required: true
16+
17+
jobs:
18+
cleanup:
19+
runs-on: ubuntu-26.04
20+
permissions:
21+
actions: write
22+
steps:
23+
- name: Delete the pull request's caches
24+
env:
25+
GH_TOKEN: ${{ github.token }}
26+
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr-number }}
27+
run: |
28+
[[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || { echo "Not a pull request number: $PR_NUMBER" >&2; exit 1; }
29+
gh api --paginate --method GET "repos/$GITHUB_REPOSITORY/actions/caches" \
30+
-f ref="refs/pull/$PR_NUMBER/merge" \
31+
--jq '.actions_caches[] | "\(.id) \(.key)"' |
32+
while read -r id key; do
33+
echo "Deleting $key"
34+
gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/caches/$id" ||
35+
echo "::warning::Couldn't delete cache $id."
36+
done

‎.github/workflows/ci.yml‎

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -104,18 +104,17 @@ jobs:
104104
id: compiler-build-state-key
105105
shell: bash
106106
run: |
107-
echo "value=compiler-build-state-v1-${{ matrix.os }}-${{ steps.setup_ocaml.outputs.setup_version }}-${{ matrix.ocaml_compiler }}-${{ hashFiles('*.opam') }}" \
107+
echo "value=compiler-build-state-v2-${{ matrix.os }}-${{ steps.setup_ocaml.outputs.setup_version }}-${{ matrix.ocaml_compiler }}-${{ hashFiles('*.opam') }}" \
108108
| sed 's/,/-/g' >> "$GITHUB_OUTPUT"
109109
110+
# Only _build: dune's shared cache under ~/.cache/dune would grow with
111+
# every master push that restores, extends and saves it again.
110112
- name: Restore compiler build state
111113
if: github.base_ref == 'master' || github.ref == 'refs/heads/master'
112114
id: compiler-build-state
113115
uses: actions/cache/restore@v6
114116
with:
115-
path: |
116-
C:\.cache\dune
117-
~/.cache/dune
118-
_build
117+
path: _build
119118
key: ${{ steps.compiler-build-state-key.outputs.value }}
120119

121120
- name: Build compiler
@@ -143,10 +142,7 @@ jobs:
143142
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
144143
uses: actions/cache/save@v6
145144
with:
146-
path: |
147-
C:\.cache\dune
148-
~/.cache/dune
149-
_build
145+
path: _build
150146
key: ${{ steps.compiler-build-state-key.outputs.value }}
151147

152148
- name: Verify promoted compiler exes
@@ -456,7 +452,7 @@ jobs:
456452
pull-requests: write
457453
env:
458454
# The same build as the first linux-x64 build-compiler job, so that the
459-
# OPAM environment and the dune cache are shared.
455+
# OPAM environment and the build state are shared.
460456
DUNE_PROFILE: static
461457
OCAML_COMPILER: ocaml-variants.5.5.2+options,ocaml-option-static
462458
steps:
@@ -492,18 +488,15 @@ jobs:
492488
- name: Compiler build state key
493489
id: compiler-build-state-key
494490
run: |
495-
echo "value=compiler-build-state-v1-ubuntu-26.04-${{ steps.setup_ocaml.outputs.setup_version }}-$OCAML_COMPILER-${{ hashFiles('*.opam') }}" \
491+
echo "value=compiler-build-state-v2-ubuntu-26.04-${{ steps.setup_ocaml.outputs.setup_version }}-$OCAML_COMPILER-${{ hashFiles('*.opam') }}" \
496492
| sed 's/,/-/g' >> "$GITHUB_OUTPUT"
497493
498494
- name: Restore compiler build state
499495
uses: actions/cache/restore@v6
500496
with:
501497
# The paths are part of the cache's version, so they must be the
502498
# ones build-compiler saves.
503-
path: |
504-
C:\.cache\dune
505-
~/.cache/dune
506-
_build
499+
path: _build
507500
key: ${{ steps.compiler-build-state-key.outputs.value }}
508501

509502
- name: Build the compiler

‎.github/workflows/coverage.yml‎

Lines changed: 4 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ jobs:
1717
coverage:
1818
runs-on: ubuntu-26.04-arm
1919
permissions:
20-
actions: write # For build cache overwriting
20+
contents: read
21+
actions: write # For deleting earlier OPAM environments
2122
id-token: write # For CodeCov OIDC
2223
env:
2324
OS: ubuntu-26.04-arm
@@ -47,48 +48,11 @@ jobs:
4748
git+https://github.com/patricoferris/bisect_ppx.git#7061d643ff492b0045796357ee6917ded21fb1f0
4849
--yes
4950
50-
# --- Coverage build cache --------------------------------------------
51-
# The bisect_ppx-instrumented dune build is a separate artifact from
52-
# the main CI build, so it gets its own key. Only restore/save on
53-
# master pushes — PRs share the master cache to stay fast but won't
54-
# poison it.
55-
- name: Coverage build state key
56-
id: coverage-build-state-key
57-
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
58-
59-
- name: Restore coverage build state
60-
if: github.base_ref == 'master' || github.ref == 'refs/heads/master'
61-
id: coverage-build-state
62-
uses: actions/cache/restore@v6
63-
with:
64-
path: |
65-
~/.cache/dune
66-
_build
67-
key: ${{ steps.coverage-build-state-key.outputs.value }}
68-
51+
# No build state is cached: every compiler change misses it, and the
52+
# instrumented build takes about 30 s.
6953
- name: Run coverage
7054
run: opam exec -- make coverage
7155

72-
- name: Delete stale coverage build state
73-
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
74-
run: |
75-
gh extension install actions/gh-actions-cache
76-
gh actions-cache delete ${{ steps.coverage-build-state-key.outputs.value }} \
77-
-R ${{ github.repository }} \
78-
-B "$GITHUB_REF" \
79-
--confirm || echo "not exist"
80-
env:
81-
GH_TOKEN: ${{ github.token }}
82-
83-
- name: Save coverage build state
84-
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
85-
uses: actions/cache/save@v6
86-
with:
87-
path: |
88-
~/.cache/dune
89-
_build
90-
key: ${{ steps.coverage-build-state-key.outputs.value }}
91-
9256
- name: Upload coverage to Codecov
9357
uses: codecov/codecov-action@v7
9458
with:

0 commit comments

Comments
 (0)