77
88 steps :
99 - name : Install dependencies
10- run : sudo apt update && sudo apt install -y wabt gotestsum
10+ run : |
11+ set -euo pipefail
12+ sudo apt-get update && sudo apt-get install -y wabt gotestsum
13+ shell : bash
14+
15+ - name : Set Node.js version
16+ id : node-version
17+ run : echo "major=24" >> "$GITHUB_OUTPUT"
18+ shell : bash
19+
20+ - name : Set Foundry version
21+ id : foundry-version
22+ run : echo "version=v1.0.0" >> "$GITHUB_OUTPUT"
23+ shell : bash
24+
25+ # Single source of truth for node_modules directories. Consumed by
26+ # cache-node-modules and the validation step. Add new workspaces ONLY here.
27+ - name : Set node_modules directory list
28+ run : |
29+ set -euo pipefail
30+ dirs=(
31+ contracts/node_modules
32+ contracts-legacy/node_modules
33+ safe-smart-account/node_modules
34+ )
35+ {
36+ echo "NODE_MODULES_CACHE_PATHS<<NM_EOF"
37+ printf '%s\n' "${dirs[@]}"
38+ echo "NM_EOF"
39+ } >> "$GITHUB_ENV"
40+ echo "NODE_MODULES_DIRS=${dirs[*]}" >> "$GITHUB_ENV"
41+ shell : bash
42+
43+ # Single source of truth for solidity cache directories. Consumed by
44+ # cache-solidity (SOLIDITY_CACHE_PATHS) and the validate/purge step
45+ # (SOLIDITY_DIRS). Add new solidity cache directories ONLY here.
46+ - name : Set solidity cache directory list
47+ run : |
48+ set -euo pipefail
49+ # Space-separated; used by SOLIDITY_DIRS (word splitting) and
50+ # SOLIDITY_CACHE_PATHS (newline-separated for actions/cache).
51+ dirs=(
52+ contracts/build
53+ contracts/out
54+ contracts-legacy/build
55+ contracts-legacy/out
56+ contracts-local/out
57+ safe-smart-account/build
58+ solgen/go
59+ )
60+ {
61+ echo "SOLIDITY_CACHE_PATHS<<SOLIDITY_EOF"
62+ printf '%s\n' "${dirs[@]}"
63+ echo "SOLIDITY_EOF"
64+ } >> "$GITHUB_ENV"
65+ echo "SOLIDITY_DIRS=${dirs[*]}" >> "$GITHUB_ENV"
1166 shell : bash
1267
68+ # setup-node only caches yarn's global tarball cache, not node_modules.
69+ # No restore-keys: for safe-smart-account, npm ci wipes and reinstalls
70+ # node_modules (wasting any partial restore); for yarn workspaces,
71+ # additive installs over a stale restore could leave orphaned packages.
72+ - name : Cache node_modules
73+ id : cache-node-modules
74+ uses : actions/cache@v5
75+ with :
76+ path : ${{ env.NODE_MODULES_CACHE_PATHS }}
77+ key : ${{ runner.os }}-node-modules-node${{ steps.node-version.outputs.major }}-${{ hashFiles('contracts/yarn.lock', 'contracts-legacy/yarn.lock', 'safe-smart-account/package-lock.json') }}
78+
1379 - name : Setup Node.js
1480 uses : actions/setup-node@v5
1581 with :
16- node-version : " 24 "
82+ node-version : ${{ steps.node-version.outputs.major }}
1783 cache : yarn
1884 cache-dependency-path : " **/yarn.lock"
1985
2086 - name : Setup Go
87+ id : setup-go
2188 uses : actions/setup-go@v6
2289 with :
2390 go-version-file : " go.mod"
91+ cache : false # See "Cache Go build and modules" step for restore-keys fallback
2492
2593 - name : Install wasm-ld
2694 run : |
27- sudo apt-get update && sudo apt-get install -y lld-14
28- sudo ln -s /usr/bin/wasm-ld-14 /usr/local/bin/wasm-ld
95+ set -euo pipefail
96+ sudo apt-get install -y lld-14
97+ sudo ln -sf /usr/bin/wasm-ld-14 /usr/local/bin/wasm-ld
2998 shell : bash
3099
31100 - name : Install rust
@@ -35,20 +104,210 @@ runs:
35104 - name : Setup Foundry
36105 uses : foundry-rs/foundry-toolchain@v1
37106 with :
38- cache : false
39- version : v1.0.0
107+ cache : true
108+ version : ${{ steps.foundry-version.outputs.version }}
40109
41- - name : Install cbindgen
42- run : cargo install --force cbindgen
110+ - name : Validate cache key inputs
111+ env :
112+ GO_VERSION : ${{ steps.setup-go.outputs.go-version }}
113+ RUST_VERSION : ${{ steps.install-rust.outputs.version }}
114+ FOUNDRY_VERSION : ${{ steps.foundry-version.outputs.version }}
115+ NODE_VERSION : ${{ steps.node-version.outputs.major }}
116+ run : |
117+ set -euo pipefail
118+ # Version strings used in cache keys must not be empty
119+ for pair in "Go:$GO_VERSION" "Rust:$RUST_VERSION" "Foundry:$FOUNDRY_VERSION" "Node:$NODE_VERSION"; do
120+ name="${pair%%:*}"; val="${pair#*:}"
121+ if [ -z "$val" ]; then
122+ echo "ERROR: $name version is empty -- cache keys will collide across versions"; exit 1
123+ fi
124+ if ! [[ "$val" =~ ^[a-zA-Z0-9._v-]+$ ]]; then
125+ echo "ERROR: $name version looks malformed: '$val' -- cache keys may not work correctly"; exit 1
126+ fi
127+ done
128+ # Auto-extract hashFiles() arguments from this file so the validation
129+ # stays in sync without maintaining a separate list.
130+ shopt -s globstar nullglob
131+ self="${GITHUB_ACTION_PATH}/action.yml"
132+ args=$(grep -oP "hashFiles\(\K[^)]+(?=\))" "$self" | tr "," "\n" | sed "s/^[[:space:]]*'//;s/'[[:space:]]*$//" | sort -u)
133+ # Fail-closed: if the regex stops matching (e.g., multi-line reformat),
134+ # surface it immediately rather than silently skipping validation.
135+ if [ -z "$args" ]; then
136+ echo "ERROR: no hashFiles() patterns extracted from $self -- grep regex may need updating"; exit 1
137+ fi
138+ # The line-by-line regex silently misses multi-line hashFiles() calls.
139+ # Guard against this by comparing call count vs extracted group count.
140+ # Only count 'key:' lines (where GHA hashFiles expressions live) to
141+ # exclude shell scripts and comments that mention hashFiles().
142+ expected=$(grep -cP '^\s*key:.*hashFiles\(' "$self")
143+ actual=$(grep -oP "hashFiles\(\K[^)]+(?=\))" "$self" | wc -l)
144+ if [ "$actual" -ne "$expected" ]; then
145+ echo "ERROR: found $expected hashFiles() calls but only extracted $actual -- possible multi-line hashFiles() call"; exit 1
146+ fi
147+ # Export for the lint step so the regex is not duplicated.
148+ {
149+ echo "HASHFILES_PATTERNS<<HASHFILES_EOF"
150+ echo "$args"
151+ echo "HASHFILES_EOF"
152+ } >> "$GITHUB_ENV"
153+ # Disable globbing during iteration so nullglob does not silently
154+ # remove unmatched patterns from the list; re-enable inside the
155+ # body for intentional expansion.
156+ set -f
157+ for pattern in $args; do
158+ set +f
159+ # shellcheck disable=SC2206 # intentional glob expansion (globstar enabled above)
160+ matches=( $pattern )
161+ set -f
162+ if [ ${#matches[@]} -eq 0 ]; then
163+ echo "ERROR: hashFiles pattern '$pattern' matches nothing -- update .github/actions/ci-setup/action.yml"; exit 1
164+ fi
165+ done
166+ set +f
43167 shell : bash
44168
45- - name : Cache Go build
169+ # Best-effort lint: warn about cache-sensitive files not covered by
170+ # any hashFiles() pattern. Produces ::warning annotations only --
171+ # never fails the build. Separate from validation because this is
172+ # advisory, not a correctness gate.
173+ - name : Lint cache key coverage
174+ run : |
175+ set -euo pipefail
176+ shopt -s globstar nullglob
177+ # Reuse patterns extracted by the validate step (avoid duplicating the regex).
178+ args="${HASHFILES_PATTERNS:-}"
179+ if [ -z "$args" ]; then
180+ echo "::warning::HASHFILES_PATTERNS is empty -- validate step may have failed or been skipped"; exit 0
181+ fi
182+ # shellcheck disable=SC2317 # function used below
183+ file_covered_by() {
184+ local file="$1"; shift
185+ for hp in "$@"; do
186+ set +f
187+ # shellcheck disable=SC2206 # intentional glob expansion (globstar enabled)
188+ local expanded=( $hp )
189+ set -f
190+ local m
191+ for m in "${expanded[@]}"; do
192+ if [ "$m" = "$file" ]; then return 0; fi
193+ done
194+ done
195+ return 1
196+ }
197+ skip_re='^(.*node_modules.*|.*/lib/.*|.*/build/.*|.*/out/.*|.*/dist/.*|nitro-testnode/.*|go-ethereum/.*|crates/.*|.*typechain.*|.*/certora/.*)$'
198+ # Pre-expand args into an array with globbing off so patterns are
199+ # preserved as literals for file_covered_by to expand on demand.
200+ set -f
201+ # shellcheck disable=SC2206 # intentional word splitting
202+ args_arr=( $args )
203+ set +f
204+ # Check lockfiles, configs, and build files.
205+ for file in **/yarn.lock **/package-lock.json **/foundry.toml **/hardhat.config.ts; do
206+ [[ "$file" =~ $skip_re || "$file" == */src/* ]] && continue
207+ file_covered_by "$file" "${args_arr[@]}" ||
208+ echo "::warning::$file is not in any hashFiles() cache key in .github/actions/ci-setup/action.yml -- may need adding"
209+ done
210+ # Check for .sol source files not covered by any hashFiles() glob.
211+ sol_patterns=()
212+ for p in "${args_arr[@]}"; do
213+ [[ "$p" == *.sol ]] && sol_patterns+=("$p")
214+ done
215+ if [ ${#sol_patterns[@]} -gt 0 ]; then
216+ uncovered_sol=()
217+ for file in **/*.sol; do
218+ [[ "$file" =~ $skip_re || "$file" == */test/* ]] && continue
219+ file_covered_by "$file" "${sol_patterns[@]}" || uncovered_sol+=("$file")
220+ done
221+ if [ ${#uncovered_sol[@]} -gt 0 ]; then
222+ echo "::warning::${#uncovered_sol[@]} .sol file(s) not covered by any hashFiles() sol glob -- may need adding: ${uncovered_sol[*]}"
223+ fi
224+ fi
225+ shell : bash
226+
227+ # restore-keys prefix fallback: Go build cache is content-addressed
228+ # (stale entries ignored), module cache self-heals by re-downloading.
229+ - name : Cache Go build and modules
230+ id : cache-go
231+ uses : actions/cache@v5
232+ with :
233+ path : |
234+ ~/.cache/go-build
235+ ~/go/pkg/mod
236+ key : ${{ runner.os }}-go-${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('go.mod', 'go.sum') }}
237+ restore-keys : |
238+ ${{ runner.os }}-go-${{ steps.setup-go.outputs.go-version }}-
239+
240+ # No restore-keys: partial Solidity restores (stale Go bindings with
241+ # new ABIs) would produce inconsistent artifacts.
242+ - name : Cache contract build artifacts
243+ id : cache-solidity
46244 uses : actions/cache@v5
47245 with :
48- path : ~/.cache/go-build
49- key : ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
246+ path : ${{ env.SOLIDITY_CACHE_PATHS }}
247+ key : ${{ runner.os }}-solidity-go${{ steps.setup-go.outputs.go-version }}-node${{ steps.node-version.outputs.major }}-foundry-${{ steps.foundry-version.outputs.version }}-${{ hashFiles('contracts/src/**/*.sol', 'contracts-legacy/src/**/*.sol', 'contracts-local/src/**/*.sol', 'contracts/foundry.toml', 'contracts-legacy/foundry.toml', 'contracts-local/foundry.toml', 'contracts/hardhat.config.ts', 'contracts-legacy/hardhat.config.ts', 'safe-smart-account/hardhat.config.ts', 'contracts-local/Makefile', 'safe-smart-account/contracts/**/*.sol', 'safe-smart-account/package.json', 'contracts/yarn.lock', 'contracts-legacy/yarn.lock', 'safe-smart-account/package-lock.json', 'solgen/gen.go', 'go-ethereum/accounts/abi/abigen/**/*.go', 'go.mod', 'go.sum') }}
248+
249+ # On solidity cache hit: validate artifacts, then either touch Make
250+ # sentinels (if node_modules also hit) or purge and rebuild (if not).
251+ - name : Validate and mark contract builds as up-to-date on cache hit
252+ if : steps.cache-solidity.outputs.cache-hit == 'true'
253+ env :
254+ NODE_MODULES_HIT : ${{ steps.cache-node-modules.outputs.cache-hit }}
255+ run : |
256+ set -euo pipefail
257+
258+ set -f
259+ for dir in $SOLIDITY_DIRS; do
260+ if [ ! -d "$dir" ]; then
261+ echo "ERROR: cached directory missing: $dir"; exit 1
262+ fi
263+ if [ -z "$(ls -A "$dir")" ]; then
264+ echo "ERROR: cached directory empty: $dir"; exit 1
265+ fi
266+ done
267+ set +f
268+ # Spot-check a known solgen output file to catch partial cache restores
269+ # (e.g., interrupted extraction) that pass the non-empty dir check above.
270+ if [ ! -f "solgen/go/bridgegen/bridgegen.go" ]; then
271+ echo "ERROR: solgen/go output looks incomplete despite cache hit -- possible partial restore"; exit 1
272+ fi
50273
274+ # Touch Make sentinels only when both solidity AND node_modules hit.
275+ mkdir -p .make
276+ if [ "$NODE_MODULES_HIT" = "true" ]; then
277+ if [ -z "${NODE_MODULES_DIRS:-}" ]; then
278+ echo "ERROR: NODE_MODULES_DIRS is empty or unset"; exit 1
279+ fi
280+ set -f
281+ for dir in $NODE_MODULES_DIRS; do
282+ test -d "$dir" || { echo "ERROR: $dir missing despite node_modules cache hit"; exit 1; }
283+ if [ -z "$(ls -A "$dir")" ]; then
284+ echo "ERROR: $dir is empty despite node_modules cache hit -- cache may be corrupted"; exit 1
285+ fi
286+ done
287+ set +f
288+ touch .make/yarndeps .make/solidity .make/solgen
289+ echo "INFO: All sentinels touched; Make will skip solidity/solgen/yarndeps targets"
290+ else
291+ # Solidity cache hit but node_modules missed -- purge cached
292+ # solidity artifacts so Make rebuilds with fresh node_modules.
293+ # One-time cost: node_modules saves on this run, so next run
294+ # both caches hit.
295+ echo "::warning::node_modules cache missed (likely evicted) -- purging cached solidity artifacts to rebuild (one-time cost)"
296+ if [ -z "${SOLIDITY_DIRS:-}" ]; then
297+ echo "ERROR: SOLIDITY_DIRS is empty or unset"; exit 1
298+ fi
299+ set -f # disable globbing for safe word splitting on $SOLIDITY_DIRS
300+ for dir in $SOLIDITY_DIRS; do
301+ rm -rf "$dir"
302+ done
303+ set +f
304+ fi
305+ shell : bash
306+
307+ # ORDERING: Must appear before "Cache cbrotli" -- the Rust cache
308+ # includes all of target/ which overlaps cbrotli paths.
51309 - name : Cache Rust build
310+ id : cache-rust
52311 uses : actions/cache@v5
53312 with :
54313 path : |
@@ -59,7 +318,23 @@ runs:
59318 target/etc/initial-machine-cache/
60319 /home/runner/.rustup/toolchains/
61320 key : ${{ runner.os }}-cargo-${{ steps.install-rust.outputs.version }}-${{ hashFiles('Cargo.lock') }}
321+ restore-keys : |
322+ ${{ runner.os }}-cargo-${{ steps.install-rust.outputs.version }}-
323+
324+ - name : Install cbindgen
325+ run : |
326+ set -euo pipefail
327+ CBINDGEN_VERSION="0.24.3"
328+ actual=$(cbindgen --version 2>/dev/null) || true
329+ if [ "$actual" != "cbindgen $CBINDGEN_VERSION" ]; then
330+ echo "Installing cbindgen $CBINDGEN_VERSION (found: '${actual:-not installed}')"
331+ cargo install cbindgen --version "$CBINDGEN_VERSION" --force
332+ else
333+ echo "cbindgen $CBINDGEN_VERSION already installed"
334+ fi
335+ shell : bash
62336
337+ # ORDERING: Must appear after "Cache Rust build" -- see comment there.
63338 - name : Cache cbrotli
64339 id : cache-cbrotli
65340 uses : actions/cache@v5
@@ -70,7 +345,17 @@ runs:
70345 target/lib/libbrotlicommon-static.a
71346 target/lib/libbrotlienc-static.a
72347 target/lib/libbrotlidec-static.a
73- key : ${{ runner.os }}-brotli-${{ hashFiles('scripts/build-brotli.sh') }}
348+ key : ${{ runner.os }}-brotli-rust${{ steps.install-rust.outputs.version }}-${{ hashFiles('scripts/build-brotli.sh') }}
349+
350+ # A partial Rust restore may place stale cbrotli artifacts in target/.
351+ # Remove them before rebuilding.
352+ - name : Clean stale cbrotli paths on cache miss
353+ if : steps.cache-cbrotli.outputs.cache-hit != 'true'
354+ run : |
355+ set -euo pipefail
356+ rm -rf target/include/brotli target/lib-wasm
357+ rm -f target/lib/libbrotli{common,enc,dec}-static.a
358+ shell : bash
74359
75360 - name : Build cbrotli-local
76361 if : steps.cache-cbrotli.outputs.cache-hit != 'true'
@@ -81,3 +366,39 @@ runs:
81366 if : steps.cache-cbrotli.outputs.cache-hit != 'true'
82367 run : ./scripts/build-brotli.sh -w -d
83368 shell : bash
369+
370+ # Runs after both cache-restore and build paths. Do not split into
371+ # separate "validate on hit" / "validate after build" steps -- identical
372+ # validation applies regardless of source, and splitting invites drift.
373+ - name : Validate cbrotli artifacts
374+ run : |
375+ set -euo pipefail
376+ for f in target/lib/libbrotli{common,enc,dec}-static.a \
377+ target/lib-wasm/libbrotli{common,enc,dec}-static.a; do
378+ [ -f "$f" ] && [ -s "$f" ] || { echo "ERROR: $f missing or empty"; exit 1; }
379+ done
380+ for f in target/include/brotli/encode.h target/include/brotli/decode.h; do
381+ test -f "$f" || { echo "ERROR: $f missing"; exit 1; }
382+ done
383+ shell : bash
384+
385+ # cache-hit is 'true' only on exact key match. For Go/Rust
386+ # (restore-keys enabled), 'false' could mean a partial restore
387+ # or a full miss.
388+ - name : Cache status summary
389+ env :
390+ NODE_MODULES_HIT : ${{ steps.cache-node-modules.outputs.cache-hit }}
391+ GO_HIT : ${{ steps.cache-go.outputs.cache-hit }}
392+ SOLIDITY_HIT : ${{ steps.cache-solidity.outputs.cache-hit }}
393+ RUST_HIT : ${{ steps.cache-rust.outputs.cache-hit }}
394+ CBROTLI_HIT : ${{ steps.cache-cbrotli.outputs.cache-hit }}
395+ run : |
396+ set -euo pipefail
397+ echo "=== Cache Status ==="
398+ printf " %-14s %s\n" \
399+ "node_modules" "${NODE_MODULES_HIT:-MISS}" \
400+ "go" "${GO_HIT:-MISS}" \
401+ "solidity" "${SOLIDITY_HIT:-MISS}" \
402+ "rust" "${RUST_HIT:-MISS}" \
403+ "cbrotli" "${CBROTLI_HIT:-MISS}"
404+ shell : bash
0 commit comments