Skip to content

Commit b78cf56

Browse files
authored
improve(ci): persist warm Vitest and Node caches (#109425)
* perf(ci): harden persistent test caches * ci: document trusted cache cleanup trigger * ci: harden composite cache inputs * perf(ci): isolate Node build bytecode cache
1 parent 85d375c commit b78cf56

17 files changed

Lines changed: 586 additions & 154 deletions

.github/actions/setup-node-env/action.yml

Lines changed: 104 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,26 @@ inputs:
3535
alongside.
3636
required: false
3737
default: "false"
38+
runtime-cache-sticky-disk:
39+
description: Whether runtime caches should use Blacksmith sticky disks instead of actions/cache.
40+
required: false
41+
default: "false"
3842
vitest-fs-cache:
3943
description: Whether to persist Vitest's experimental filesystem module cache.
4044
required: false
4145
default: "false"
46+
node-compile-cache:
47+
description: Whether to persist Node's on-disk V8 compile cache.
48+
required: false
49+
default: "false"
50+
node-compile-cache-scope:
51+
description: Cache namespace for isolating workloads with different writer ownership.
52+
required: false
53+
default: "test"
54+
save-node-compile-cache:
55+
description: Whether this job may save the Node compile cache.
56+
required: false
57+
default: "false"
4258
save-vitest-fs-cache:
4359
description: >
4460
Whether this job may save the shared Vitest filesystem module cache.
@@ -103,46 +119,123 @@ runs:
103119
# detection, so an incomplete first hydration cannot seed this key.
104120
commit: if-missing
105121

122+
- name: Mount protected Vitest transform seed
123+
if: inputs.vitest-fs-cache == 'true' && (inputs.sticky-disk == 'true' || inputs.runtime-cache-sticky-disk == 'true') && github.event_name == 'pull_request' && runner.os != 'Windows'
124+
uses: useblacksmith/stickydisk@5b350170ae4ef55b536b548ef5f5896e76a6b54f # v1.4.0
125+
with:
126+
# Same-repository PRs read the protected snapshot but never write it.
127+
# Content-addressed misses stay in the PR-scoped overlay below.
128+
key: ${{ github.repository }}-vitest-fs-v2-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}
129+
path: /var/tmp/openclaw-vitest-fs-protected-seed
130+
commit: false
131+
106132
- name: Mount Vitest transform cache sticky disk
107-
if: inputs.vitest-fs-cache == 'true' && inputs.sticky-disk == 'true' && runner.os != 'Windows'
133+
if: inputs.vitest-fs-cache == 'true' && (inputs.sticky-disk == 'true' || inputs.runtime-cache-sticky-disk == 'true') && runner.os != 'Windows'
108134
uses: useblacksmith/stickydisk@5b350170ae4ef55b536b548ef5f5896e76a6b54f # v1.4.0
109135
with:
110-
# Match dependency-cache trust scoping. Vitest content-addresses module
111-
# entries; the lockfile key rotates dependency/plugin generations.
112-
key: ${{ github.repository }}-vitest-fs-v1-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'protected' }}-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml') }}
136+
# One stable disk per trust scope. The generation guard below clears
137+
# incompatible transform inputs without leaking one disk per lockfile.
138+
key: ${{ github.repository }}-vitest-fs-v2-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'protected' }}-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}
113139
path: /var/tmp/openclaw-vitest-fs-cache
114-
commit: ${{ inputs.save-vitest-fs-cache == 'true' && 'on-change' || 'false' }}
140+
# One semantic writer only. Explicit true avoids stickydisk's allocated-
141+
# byte heuristic missing equal-size replacements or prune-balanced writes.
142+
commit: ${{ inputs.save-vitest-fs-cache == 'true' && 'true' || 'false' }}
115143

116144
- name: Restore and save Vitest transform cache
117-
if: inputs.vitest-fs-cache == 'true' && inputs.sticky-disk != 'true' && inputs.save-vitest-fs-cache == 'true' && runner.os != 'Windows'
145+
if: inputs.vitest-fs-cache == 'true' && inputs.sticky-disk != 'true' && inputs.runtime-cache-sticky-disk != 'true' && inputs.save-vitest-fs-cache == 'true' && runner.os != 'Windows'
118146
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
119147
with:
120148
path: /var/tmp/openclaw-vitest-fs-cache
121149
# actions/cache entries are immutable. One matrix job writes a new
122150
# run key; every job restores the newest cache through coarse prefixes.
123-
key: ${{ github.repository }}-vitest-fs-v1-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'protected' }}-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.run_id }}
151+
key: ${{ github.repository }}-vitest-fs-v2-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'protected' }}-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**') }}-${{ github.run_id }}-${{ github.run_attempt }}
124152
restore-keys: |
125-
${{ github.repository }}-vitest-fs-v1-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'protected' }}-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml') }}-
153+
${{ github.repository }}-vitest-fs-v2-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'protected' }}-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**') }}-
126154
127155
- name: Restore Vitest transform cache
128-
if: inputs.vitest-fs-cache == 'true' && inputs.sticky-disk != 'true' && inputs.save-vitest-fs-cache != 'true' && runner.os != 'Windows'
156+
if: inputs.vitest-fs-cache == 'true' && inputs.sticky-disk != 'true' && inputs.runtime-cache-sticky-disk != 'true' && inputs.save-vitest-fs-cache != 'true' && runner.os != 'Windows'
129157
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
130158
with:
131159
path: /var/tmp/openclaw-vitest-fs-cache
132-
key: ${{ github.repository }}-vitest-fs-v1-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'protected' }}-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.run_id }}
160+
key: ${{ github.repository }}-vitest-fs-v2-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'protected' }}-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**') }}-${{ github.run_id }}-${{ github.run_attempt }}
133161
restore-keys: |
134-
${{ github.repository }}-vitest-fs-v1-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'protected' }}-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml') }}-
162+
${{ github.repository }}-vitest-fs-v2-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'protected' }}-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**') }}-
135163
136164
- name: Configure Vitest transform cache
137165
if: inputs.vitest-fs-cache == 'true' && runner.os != 'Windows'
166+
env:
167+
CACHE_GENERATION: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**') }}
168+
CACHE_WRITER: ${{ inputs.save-vitest-fs-cache == 'true' && '1' || '0' }}
138169
shell: bash
139170
run: |
140171
set -euo pipefail
141172
cache_root=/var/tmp/openclaw-vitest-fs-cache
173+
seed_root=/var/tmp/openclaw-vitest-fs-protected-seed
174+
generation_file="$cache_root/.openclaw-transform-generation"
142175
mkdir -p "$cache_root"
176+
cache_generation=""
177+
if [[ -f "$generation_file" ]]; then
178+
cache_generation="$(<"$generation_file")"
179+
fi
180+
cache_entry="$(find "$cache_root" -mindepth 1 -maxdepth 1 -print -quit)"
181+
if [[ -n "$cache_entry" ]] && [[ "$cache_generation" != "$CACHE_GENERATION" ]]; then
182+
echo "Vitest transform inputs changed; clearing incompatible cache generation"
183+
find "$cache_root" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
184+
fi
185+
seed_generation_file="$seed_root/.openclaw-transform-generation"
186+
if [[ -f "$seed_generation_file" ]] && [[ "$(<"$seed_generation_file")" == "$CACHE_GENERATION" ]]; then
187+
echo "Merging matching protected Vitest transform seed"
188+
cp -a --no-clobber "$seed_root"/. "$cache_root"/
189+
elif [[ -f "$seed_generation_file" ]]; then
190+
echo "Ignoring protected Vitest transform seed from an incompatible generation"
191+
fi
192+
printf '%s\n' "$CACHE_GENERATION" > "$generation_file"
143193
# The shard runner treats this as a persistent root and assigns one
144194
# isolated subdirectory per concurrent Vitest worker.
145195
echo "OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=$cache_root" >> "$GITHUB_ENV"
196+
echo "OPENCLAW_VITEST_FS_MODULE_CACHE_WRITER=$CACHE_WRITER" >> "$GITHUB_ENV"
197+
198+
- name: Mount Node compile cache sticky disk
199+
if: inputs.node-compile-cache == 'true' && (inputs.sticky-disk == 'true' || inputs.runtime-cache-sticky-disk == 'true') && runner.os != 'Windows'
200+
uses: useblacksmith/stickydisk@5b350170ae4ef55b536b548ef5f5896e76a6b54f # v1.4.0
201+
with:
202+
# Trusted PRs read the protected dependency bytecode seed but cannot
203+
# publish feature-branch code into it. Node isolates exact versions and
204+
# invalidates bytecode when module contents change.
205+
key: ${{ github.repository }}-node-compile-v2-${{ inputs.node-compile-cache-scope }}-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}
206+
path: /var/tmp/openclaw-node-compile-cache
207+
commit: ${{ inputs.save-node-compile-cache == 'true' && github.event_name != 'pull_request' && 'true' || 'false' }}
208+
209+
- name: Restore and save Node compile cache
210+
if: inputs.node-compile-cache == 'true' && inputs.sticky-disk != 'true' && inputs.runtime-cache-sticky-disk != 'true' && inputs.save-node-compile-cache == 'true' && runner.os != 'Windows'
211+
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
212+
with:
213+
path: /var/tmp/openclaw-node-compile-cache
214+
key: ${{ github.repository }}-node-compile-v2-${{ inputs.node-compile-cache-scope }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'protected' }}-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ github.run_id }}-${{ github.run_attempt }}
215+
restore-keys: |
216+
${{ github.repository }}-node-compile-v2-${{ inputs.node-compile-cache-scope }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'protected' }}-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-
217+
218+
- name: Restore Node compile cache
219+
if: inputs.node-compile-cache == 'true' && inputs.sticky-disk != 'true' && inputs.runtime-cache-sticky-disk != 'true' && inputs.save-node-compile-cache != 'true' && runner.os != 'Windows'
220+
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
221+
with:
222+
path: /var/tmp/openclaw-node-compile-cache
223+
key: ${{ github.repository }}-node-compile-v2-${{ inputs.node-compile-cache-scope }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'protected' }}-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ github.run_id }}-${{ github.run_attempt }}
224+
restore-keys: |
225+
${{ github.repository }}-node-compile-v2-${{ inputs.node-compile-cache-scope }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'protected' }}-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-
226+
227+
- name: Configure Node compile cache
228+
if: inputs.node-compile-cache == 'true' && runner.os != 'Windows'
229+
env:
230+
CACHE_WRITER: ${{ inputs.save-node-compile-cache == 'true' && ((inputs.sticky-disk != 'true' && inputs.runtime-cache-sticky-disk != 'true') || github.event_name != 'pull_request') && '1' || '0' }}
231+
shell: bash
232+
run: |
233+
set -euo pipefail
234+
cache_root=/var/tmp/openclaw-node-compile-cache
235+
mkdir -p "$cache_root"
236+
echo "NODE_COMPILE_CACHE=$cache_root" >> "$GITHUB_ENV"
237+
echo "NODE_COMPILE_CACHE_PORTABLE=1" >> "$GITHUB_ENV"
238+
echo "OPENCLAW_NODE_COMPILE_CACHE_WRITER=$CACHE_WRITER" >> "$GITHUB_ENV"
146239
147240
# Post actions run last-in-first-out. Register after stickydisk so this bind
148241
# is gone before stickydisk flushes, unmounts, and snapshots its filesystem.

.github/workflows/ci.yml

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -567,14 +567,23 @@ jobs:
567567
runNodeFull &&
568568
changedScopeHasQaImpact &&
569569
(!frozenTarget || existsSync("extensions/qa-lab/src/ci-smoke-plan.ts"));
570-
const nodeTestShards = runNodeFull
571-
? (changedNodeTestShards
572-
? changedNodeTestShards
573-
: createNodeTestPlan({
574-
includeReleaseOnlyPluginShards: false,
575-
compact: compactPullRequest,
576-
})
577-
).map((shard) => ({
570+
const rawNodeTestShards = runNodeFull
571+
? changedNodeTestShards
572+
? changedNodeTestShards
573+
: createNodeTestPlan({
574+
includeReleaseOnlyPluginShards: false,
575+
compact: compactPullRequest,
576+
})
577+
: [];
578+
const assignVitestFsCacheWriter =
579+
typeof nodeTestPlan.assignVitestFsCacheWriter === "function"
580+
? nodeTestPlan.assignVitestFsCacheWriter
581+
: (shards) =>
582+
shards.map((shard, index) => ({
583+
...shard,
584+
saveVitestFsCache: index === 0,
585+
}));
586+
const nodeTestShards = assignVitestFsCacheWriter(rawNodeTestShards).map((shard) => ({
578587
check_name: shard.checkName,
579588
runtime: "node",
580589
task: "test-shard",
@@ -587,12 +596,12 @@ jobs:
587596
runner: shard.runner,
588597
timeout_minutes: shard.timeoutMinutes,
589598
plan_concurrency: shard.planConcurrency,
599+
save_vitest_fs_cache: shard.saveVitestFsCache,
590600
targets: shard.targets,
591601
requires_go:
592602
shard.shardName === "core-tooling" ||
593603
shard.groups?.some((group) => group.shard_name.startsWith("core-tooling")),
594-
}))
595-
: [];
604+
}));
596605
const nodeTestNonDistShards = nodeTestShards.filter((shard) => !shard.requires_dist);
597606
const nodeTestDistShards = nodeTestShards.filter((shard) => shard.requires_dist);
598607
// Targeted jobs cannot discover repository-scanning boundary tests
@@ -951,6 +960,10 @@ jobs:
951960
uses: ./.github/actions/setup-node-env
952961
with:
953962
install-bun: "false"
963+
node-compile-cache: "true"
964+
node-compile-cache-scope: "build"
965+
runtime-cache-sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
966+
save-node-compile-cache: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'true' || 'false' }}
954967

955968
- name: Restore build-all step cache
956969
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
@@ -1716,11 +1729,15 @@ jobs:
17161729
# Fork PRs must keep actions/cache: sticky snapshots are writable,
17171730
# repository-global state and must never be produced by fork code.
17181731
sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
1732+
runtime-cache-sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
17191733
use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }}
17201734
vitest-fs-cache: "true"
1735+
node-compile-cache: "true"
1736+
node-compile-cache-scope: "test"
17211737
# One matrix job commits the shared warm seed. Other jobs restore it
17221738
# read-only, avoiding concurrent sticky-disk or archive writers.
1723-
save-vitest-fs-cache: ${{ strategy.job-index == 0 && 'true' || 'false' }}
1739+
save-vitest-fs-cache: ${{ matrix.save_vitest_fs_cache && 'true' || 'false' }}
1740+
save-node-compile-cache: ${{ matrix.save_vitest_fs_cache && 'true' || 'false' }}
17241741

17251742
- name: Setup Go for docs i18n
17261743
if: matrix.requires_go == true
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: PR Cache Cleanup
2+
3+
on:
4+
pull_request_target: # zizmor: ignore[dangerous-triggers] trusted base workflow only; no checkout or untrusted PR code execution
5+
types: [closed]
6+
7+
permissions:
8+
actions: write
9+
contents: read
10+
11+
jobs:
12+
cleanup:
13+
if: github.repository == 'openclaw/openclaw'
14+
runs-on: ubuntu-24.04
15+
timeout-minutes: 5
16+
env:
17+
GH_TOKEN: ${{ github.token }}
18+
PR_CACHE_REF: refs/pull/${{ github.event.pull_request.number }}/merge
19+
steps:
20+
- name: Delete closed PR caches
21+
run: gh cache delete --repo "$GITHUB_REPOSITORY" --all --ref "$PR_CACHE_REF" --succeed-on-no-caches
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
name: Vitest Cache Warm
2+
3+
on:
4+
repository_dispatch:
5+
types: [vitest-cache-warm]
6+
schedule:
7+
- cron: "17 8 * * *"
8+
9+
permissions:
10+
contents: read
11+
12+
concurrency:
13+
group: vitest-cache-warm
14+
cancel-in-progress: false
15+
16+
jobs:
17+
warm:
18+
if: github.repository == 'openclaw/openclaw'
19+
runs-on: blacksmith-8vcpu-ubuntu-2404
20+
timeout-minutes: 30
21+
steps:
22+
- name: Checkout
23+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
24+
25+
- name: Setup Node environment
26+
uses: ./.github/actions/setup-node-env
27+
with:
28+
install-bun: "false"
29+
node-compile-cache: "true"
30+
node-compile-cache-scope: "test"
31+
runtime-cache-sticky-disk: "true"
32+
save-node-compile-cache: "true"
33+
save-vitest-fs-cache: "true"
34+
sticky-disk: "true"
35+
use-actions-cache: "false"
36+
vitest-fs-cache: "true"
37+
38+
- name: Select broad cache seed
39+
shell: bash
40+
run: |
41+
set -euo pipefail
42+
node --input-type=module <<'EOF'
43+
import { appendFileSync } from "node:fs";
44+
import { createNodeTestShards } from "./scripts/lib/ci-node-test-plan.mjs";
45+
46+
const shard = createNodeTestShards().find((candidate) => candidate.shardName === "core-unit-fast");
47+
if (!shard) {
48+
throw new Error("core-unit-fast cache seed shard is missing");
49+
}
50+
appendFileSync(
51+
process.env.GITHUB_ENV,
52+
[
53+
`OPENCLAW_NODE_TEST_CONFIGS_JSON=${JSON.stringify(shard.configs)}`,
54+
`OPENCLAW_VITEST_SHARD_NAME=${shard.shardName}`,
55+
"OPENCLAW_NODE_TEST_PLAN_CONCURRENCY=1",
56+
].join("\n") + "\n",
57+
);
58+
EOF
59+
60+
- name: Warm transform and compile caches
61+
run: node scripts/ci-run-node-test-shard.mjs

0 commit comments

Comments
 (0)