fix(ci): persist warm runtime caches across runs (#110363)

* ci: make cache persistence verifiable

* test: fix cache guard CI failures

* test: narrow cache maintenance env

* fix(ci): stabilize vitest cache fingerprint

* test: stabilize lagging-history outbox check
This commit is contained in:
Peter Steinberger
2026-07-18 08:21:26 +01:00
committed by GitHub
parent f93bfc240b
commit 64f3397874
8 changed files with 233 additions and 236 deletions
+7 -1
View File
@@ -128,7 +128,10 @@ These are intentionally guarded by `test/scripts/ci-workflow-guards.test.ts`:
- `CI` concurrency key version, PR cancellation, and non-canceling canonical
`main` single-flight with one coalesced pending tip.
- `preflight` and hosted `security-fast` start immediately without a debounce
or standalone admission job.
or standalone admission job. On Node-relevant canonical main pushes,
preflight also owns the sole dependency sticky-disk write and 8 GiB prune
before fanout; replacement visibility is proved only by a later exact-marker
restore because Blacksmith snapshot promotion can lag job completion.
- CI matrix caps: fast/check lanes at 12, Node test shards at 28, Windows and
Android at 2.
- Canonical PR Node tests use one precise changed-target job when possible;
@@ -139,6 +142,9 @@ These are intentionally guarded by `test/scripts/ci-workflow-guards.test.ts`:
- lower-weight Node/check shards on `blacksmith-4vcpu-ubuntu-2404`.
- heavy retained Linux/Android shards on `blacksmith-8vcpu-ubuntu-2404`.
- CodeQL Critical Quality on `ubuntu-24.04` with no `blacksmith-` labels.
- Vitest/test compile caches are restore-only in CI and use immutable Actions
caches; the daily/dispatch warmer is their sole writer. Build compile cache
writes rotate at most once per UTC day. PRs create no runtime-cache archives.
When changing one knob, update `docs/ci.md` and the guard test in the same PR.
+42 -68
View File
@@ -43,10 +43,6 @@ inputs:
snapshot.
required: false
default: "false"
runtime-cache-sticky-disk:
description: Whether runtime caches should use Blacksmith sticky disks instead of actions/cache.
required: false
default: "false"
vitest-fs-cache:
description: Whether to persist Vitest's experimental filesystem module cache.
required: false
@@ -64,10 +60,7 @@ inputs:
required: false
default: "false"
save-vitest-fs-cache:
description: >
Whether this job may save the shared Vitest filesystem module cache.
Only honored outside pull_request events for sticky disks; PR jobs
always mount the protected snapshot read-only.
description: Whether this job may save the shared Vitest filesystem module cache.
required: false
default: "false"
build-all-cache-scope:
@@ -122,15 +115,15 @@ runs:
- name: Mount dependency sticky disk
if: inputs.sticky-disk == 'true'
uses: useblacksmith/stickydisk@5b350170ae4ef55b536b548ef5f5896e76a6b54f # v1.4.0
uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # main (post-v1.4.0 hot-attach fix)
with:
# One stable disk per Node line. v4 resets the v3 lineage after
# Blacksmith repeatedly acknowledged commits but kept restoring its
# original snapshot. The v2 per-PR/per-manifest-hash keys
# One stable disk per Node line. v5 starts a fresh lineage for the
# preflight-serialized writer after Blacksmith acknowledged repeated v4
# commits but kept restoring its original snapshot. The v2 per-PR/per-manifest-hash keys
# saturated Blacksmith's installation-wide sticky-disk budget. Install
# inputs, runner platform, and the exact Node patch live in the runtime
# marker below, so changes refresh this disk in place.
key: ${{ github.repository }}-node-deps-bind-v4-${{ inputs.node-version }}
key: ${{ github.repository }}-node-deps-bind-v5-${{ inputs.node-version }}
path: /var/tmp/openclaw-node-deps
# Single semantic writer: only the designated trusted-push job may
# commit, so pull_request clones stay read-only. Like every sticky
@@ -139,58 +132,37 @@ runs:
# and same-repo PR authors already hold repository write access.
# Explicit true (not on-change) because the allocated-byte heuristic
# can miss a fingerprint refresh whose reinstall keeps disk usage
# stable, permanently stranding consumers on a stale marker. v1.4.0
# skips commit after failed/cancelled steps, so a broken install
# cannot seed this key.
# stable, permanently stranding consumers on a stale marker. The action
# skips commit after failed/cancelled steps, so a broken install cannot
# seed this key.
commit: ${{ inputs.save-sticky-disk == 'true' && github.event_name != 'pull_request' && 'true' || 'false' }}
- name: Mount Vitest transform cache sticky disk
if: inputs.vitest-fs-cache == 'true' && (inputs.sticky-disk == 'true' || inputs.runtime-cache-sticky-disk == 'true') && runner.os != 'Windows'
uses: useblacksmith/stickydisk@5b350170ae4ef55b536b548ef5f5896e76a6b54f # v1.4.0
with:
# One stable disk per runner shape. The old per-PR overlay disks
# minted a new backing disk for every PR and helped saturate
# Blacksmith's installation-wide sticky-disk budget, 429-failing every
# mount. Cache entries are content-hash keyed (id+content+env), so PRs
# can safely read the protected snapshot directly; PR-local misses are
# rebuilt in the discarded mount. The generation guard below clears
# incompatible transform inputs without leaking one disk per lockfile.
key: ${{ github.repository }}-vitest-fs-v2-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}
path: /var/tmp/openclaw-vitest-fs-cache
# Single semantic writer: only the designated non-PR writer commits;
# pull_request mounts stay read-only clones. Explicit true avoids
# stickydisk's allocated-byte heuristic missing equal-size
# replacements or prune-balanced writes.
commit: ${{ inputs.save-vitest-fs-cache == 'true' && github.event_name != 'pull_request' && 'true' || 'false' }}
- name: Restore and save Vitest transform cache
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'
if: inputs.vitest-fs-cache == 'true' && inputs.save-vitest-fs-cache == 'true' && runner.os != 'Windows'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: /var/tmp/openclaw-vitest-fs-cache
# actions/cache entries are immutable. One matrix job writes a new
# run key; every job restores the newest cache through coarse prefixes.
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 }}
# Blacksmith transparently accelerates the upstream Actions cache API.
# The scheduled/dispatch warmer writes one immutable protected archive;
# all CI shards restore it into isolated runner-local directories.
key: ${{ github.repository }}-vitest-fs-v3-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**', '!**/node_modules/**') }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: |
${{ 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.repository }}-vitest-fs-v3-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**', '!**/node_modules/**') }}-
- name: Restore Vitest transform cache
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'
if: inputs.vitest-fs-cache == 'true' && inputs.save-vitest-fs-cache != 'true' && runner.os != 'Windows'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: /var/tmp/openclaw-vitest-fs-cache
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 }}
key: ${{ github.repository }}-vitest-fs-v3-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**', '!**/node_modules/**') }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: |
${{ 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.repository }}-vitest-fs-v3-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**', '!**/node_modules/**') }}-
- name: Configure Vitest transform cache
if: inputs.vitest-fs-cache == 'true' && runner.os != 'Windows'
env:
CACHE_GENERATION: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**') }}
# Sticky PR mounts never commit, so pruning there would only burn
# shard wall clock on a discarded clone; non-sticky (actions/cache)
# writers still prune their PR-scoped archives.
CACHE_WRITER: ${{ inputs.save-vitest-fs-cache == 'true' && ((inputs.sticky-disk != 'true' && inputs.runtime-cache-sticky-disk != 'true') || github.event_name != 'pull_request') && '1' || '0' }}
CACHE_GENERATION: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**', '!**/node_modules/**') }}
CACHE_WRITER: ${{ inputs.save-vitest-fs-cache == 'true' && '1' || '0' }}
shell: bash
run: |
set -euo pipefail
@@ -202,9 +174,8 @@ runs:
cache_generation="$(<"$generation_file")"
fi
cache_entry="$(find "$cache_root" -mindepth 1 -maxdepth 1 -print -quit)"
# Read-only PR mounts clear a mismatched generation too: the wipe only
# affects the discarded local clone, and mixing generations could
# false-hit transforms produced under incompatible tsconfig/lockfile.
# Every restore is runner-local. Never mix incompatible transform
# inputs even when an older archive was selected through a prefix.
if [[ -n "$cache_entry" ]] && [[ "$cache_generation" != "$CACHE_GENERATION" ]]; then
echo "Vitest transform inputs changed; clearing incompatible cache generation"
find "$cache_root" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
@@ -215,39 +186,42 @@ runs:
echo "OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=$cache_root" >> "$GITHUB_ENV"
echo "OPENCLAW_VITEST_FS_MODULE_CACHE_WRITER=$CACHE_WRITER" >> "$GITHUB_ENV"
- name: Mount Node compile cache sticky disk
if: inputs.node-compile-cache == 'true' && (inputs.sticky-disk == 'true' || inputs.runtime-cache-sticky-disk == 'true') && runner.os != 'Windows'
uses: useblacksmith/stickydisk@5b350170ae4ef55b536b548ef5f5896e76a6b54f # v1.4.0
with:
# Trusted PRs read the protected dependency bytecode seed but cannot
# publish feature-branch code into it. Node isolates exact versions and
# invalidates bytecode when module contents change.
key: ${{ github.repository }}-node-compile-v2-${{ inputs.node-compile-cache-scope }}-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}
path: /var/tmp/openclaw-node-compile-cache
commit: ${{ inputs.save-node-compile-cache == 'true' && github.event_name != 'pull_request' && 'true' || 'false' }}
- name: Select Node compile cache epoch
id: node-compile-cache-epoch
if: inputs.node-compile-cache == 'true' && runner.os != 'Windows'
shell: bash
env:
CACHE_SCOPE: ${{ inputs.node-compile-cache-scope }}
run: |
set -euo pipefail
if [ "$CACHE_SCOPE" = "build" ]; then
echo "value=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
else
echo "value=${GITHUB_RUN_ID:?}-${GITHUB_RUN_ATTEMPT:?}" >> "$GITHUB_OUTPUT"
fi
- name: Restore and save Node compile cache
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'
if: inputs.node-compile-cache == 'true' && inputs.save-node-compile-cache == 'true' && runner.os != 'Windows'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: /var/tmp/openclaw-node-compile-cache
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 }}
key: ${{ github.repository }}-node-compile-v3-${{ inputs.node-compile-cache-scope }}-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ steps.node-compile-cache-epoch.outputs.value }}
restore-keys: |
${{ 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.repository }}-node-compile-v3-${{ inputs.node-compile-cache-scope }}-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-
- name: Restore Node compile cache
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'
if: inputs.node-compile-cache == 'true' && inputs.save-node-compile-cache != 'true' && runner.os != 'Windows'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: /var/tmp/openclaw-node-compile-cache
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 }}
key: ${{ github.repository }}-node-compile-v3-${{ inputs.node-compile-cache-scope }}-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ steps.node-compile-cache-epoch.outputs.value }}
restore-keys: |
${{ 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.repository }}-node-compile-v3-${{ inputs.node-compile-cache-scope }}-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-
- name: Configure Node compile cache
if: inputs.node-compile-cache == 'true' && runner.os != 'Windows'
env:
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' }}
CACHE_WRITER: ${{ inputs.save-node-compile-cache == 'true' && '1' || '0' }}
shell: bash
run: |
set -euo pipefail
+22 -1
View File
@@ -1 +1,22 @@
[]
[
{
"key": "openclaw/openclaw-node-compile-v2-build-protected-Linux-X64-node-24.x",
"architecture": "amd64",
"region": "eu-west"
},
{
"key": "openclaw/openclaw-node-compile-v2-test-protected-Linux-X64-node-24.x",
"architecture": "amd64",
"region": "eu-west"
},
{
"key": "openclaw/openclaw-node-deps-bind-v4-24.x",
"architecture": "amd64",
"region": "eu-west"
},
{
"key": "openclaw/openclaw-vitest-fs-v2-protected-Linux-X64-node-24.x",
"architecture": "amd64",
"region": "eu-west"
}
]
+53 -13
View File
@@ -745,6 +745,51 @@ jobs:
if: steps.manifest.outputs.run_protocol_event_coverage == 'true'
run: node scripts/check-protocol-event-coverage.mjs
# Canonical main cannot be cancelled by a newer push. Publish the sole
# dependency snapshot here before fanout. Blacksmith may expose a fresh
# commit only to a later run; readers retain the marker-checked fallback.
- name: Refresh sticky dependency snapshot
if: github.repository == 'openclaw/openclaw' && github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.manifest.outputs.run_node == 'true'
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
save-sticky-disk: "true"
sticky-disk: "true"
use-actions-cache: "false"
- name: Maintain sticky dependency store budget
if: github.repository == 'openclaw/openclaw' && github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.manifest.outputs.run_node == 'true'
shell: bash
env:
OPENCLAW_PNPM_STORE_MAX_KIB: "8388608"
run: |
set -euo pipefail
store_dir="${PNPM_CONFIG_STORE_DIR:?}"
before_kib="$(du -sk "$store_dir" | cut -f1)"
after_kib="$before_kib"
pruned=false
if [ "$before_kib" -gt "$OPENCLAW_PNPM_STORE_MAX_KIB" ]; then
echo "pnpm store is ${before_kib} KiB; pruning above ${OPENCLAW_PNPM_STORE_MAX_KIB} KiB ceiling"
PNPM_CONFIG_STORE_DIR="$store_dir" pnpm store prune
after_kib="$(du -sk "$store_dir" | cut -f1)"
pruned=true
else
echo "pnpm store is ${before_kib} KiB; below ${OPENCLAW_PNPM_STORE_MAX_KIB} KiB ceiling"
fi
{
echo "### Dependency store maintenance"
echo
echo "- Before: ${before_kib} KiB"
echo "- After: ${after_kib} KiB"
echo "- Pruned: ${pruned}"
} >> "$GITHUB_STEP_SUMMARY"
if [ "$after_kib" -gt "$OPENCLAW_PNPM_STORE_MAX_KIB" ]; then
echo "::warning::pnpm store remains above its 8 GiB maintenance ceiling after prune"
fi
# Run dependency-free security checks on a hosted runner in parallel with
# scope detection. No downstream job waits for Python/pre-commit setup.
security-fast:
@@ -909,13 +954,13 @@ jobs:
- name: Audit production dependencies
run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
# Warm the lockfile- and pnpm-pinned store without blocking Linux Node shards.
# On a cold key this job owns the save for later workflow runs.
# Warm the lockfile- and pnpm-pinned Actions cache for PR and manual runs.
# Canonical main publishes its sticky snapshot in preflight before fanout.
pnpm-store-warmup:
permissions:
contents: read
needs: [preflight]
if: needs.preflight.outputs.run_node == 'true' || needs.preflight.outputs.run_check_docs == 'true'
if: ${{ (needs.preflight.outputs.run_node == 'true' || needs.preflight.outputs.run_check_docs == 'true') && !(github.repository == 'openclaw/openclaw' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 20
steps:
@@ -1007,7 +1052,6 @@ jobs:
# repository-global state and must never be produced by fork code.
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' }}
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' }}
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' }}
save-node-compile-cache: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'true' || 'false' }}
- name: Restore build-all step cache
@@ -1805,16 +1849,12 @@ jobs:
# Fork PRs must keep actions/cache: sticky snapshots are writable,
# repository-global state and must never be produced by fork code.
sticky-disk: ${{ (matrix.node_version == null || matrix.node_version == '24.x') && 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' }}
runtime-cache-sticky-disk: ${{ (matrix.node_version == null || matrix.node_version == '24.x') && 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' }}
use-actions-cache: ${{ (matrix.node_version == null || matrix.node_version == '24.x') && 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' }}
vitest-fs-cache: "true"
node-compile-cache: "true"
node-compile-cache-scope: "test"
# One matrix job is the designated warm-seed writer; the action only
# honors it outside pull_request events, so PR shards always read the
# protected snapshot without committing competing writes.
save-vitest-fs-cache: ${{ matrix.save_vitest_fs_cache && 'true' || 'false' }}
save-node-compile-cache: ${{ matrix.save_vitest_fs_cache && 'true' || 'false' }}
# CI shards only read the protected immutable seed. The scheduled or
# explicitly dispatched warmer is the sole test-cache writer.
- name: Setup Go for docs i18n
if: matrix.requires_go == true
@@ -1962,7 +2002,7 @@ jobs:
# protected boundary lane may publish this repository-global snapshot.
- name: Mount extension boundary sticky disk
if: matrix.task == 'lint' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw')
uses: useblacksmith/stickydisk@5b350170ae4ef55b536b548ef5f5896e76a6b54f # v1.4.0
uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # main (post-v1.4.0 hot-attach fix)
with:
# One stable disk for the whole repository. The v1 per-PR/per-config
# keys minted a new backing disk for every PR and toolchain change
@@ -2205,7 +2245,7 @@ jobs:
# repository-global snapshots, so they keep the GitHub cache path.
- name: Mount extension boundary sticky disk
if: matrix.group == 'extension-package-boundary' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw')
uses: useblacksmith/stickydisk@5b350170ae4ef55b536b548ef5f5896e76a6b54f # v1.4.0
uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # main (post-v1.4.0 hot-attach fix)
with:
# One stable disk for the whole repository. The v1 per-PR/per-config
# keys minted a new backing disk for every PR and toolchain change
@@ -3131,7 +3171,7 @@ jobs:
# runs-on already selects non-Blacksmith ubuntu-24.04 runners).
- name: Mount Gradle sticky disk
if: github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw')
uses: useblacksmith/stickydisk@5b350170ae4ef55b536b548ef5f5896e76a6b54f # v1.4.0
uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # main (post-v1.4.0 hot-attach fix)
with:
# One stable disk per matrix task. The v1 per-PR/per-dependency-hash
# keys minted a new backing disk for every PR and dependency bump
+10 -59
View File
@@ -5,10 +5,6 @@ on:
types: [vitest-cache-warm]
schedule:
- cron: "17 8 * * *"
workflow_run: # zizmor: ignore[dangerous-triggers] successful main-push CI only; read-only default-branch checkout, no artifacts or untrusted ref
workflows: [CI]
branches: [main]
types: [completed]
permissions:
contents: read
@@ -19,73 +15,29 @@ concurrency:
jobs:
warm:
# Canonical main CI is single-flight, so only a completed green integration
# cycle may refresh the dependency snapshot. Scheduled/dispatch runs remain
# the transform and compile cache writers.
if: github.repository == 'openclaw/openclaw' && (github.event_name != 'workflow_run' || (github.event.workflow_run.event == 'push' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main'))
# Dependency snapshots are serialized before main CI fanout. This workflow
# writes only the transform and compile caches from scheduled/trusted runs.
if: github.repository == 'openclaw/openclaw'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
# workflow_run's github.sha follows the current default-branch tip,
# which can be newer than the green run that authorized this write.
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
node-compile-cache: ${{ github.event_name != 'workflow_run' && 'true' || 'false' }}
node-compile-cache: "true"
node-compile-cache-scope: "test"
runtime-cache-sticky-disk: ${{ github.event_name != 'workflow_run' && 'true' || 'false' }}
save-node-compile-cache: ${{ github.event_name != 'workflow_run' && 'true' || 'false' }}
# This disposable, non-cancelled workflow is the sole dependency
# snapshot writer. Main CI completion coalesces refreshes without
# putting required build jobs in a cancel-on-pending mutex.
save-sticky-disk: "true"
save-vitest-fs-cache: ${{ github.event_name != 'workflow_run' && 'true' || 'false' }}
sticky-disk: "true"
use-actions-cache: "false"
vitest-fs-cache: ${{ github.event_name != 'workflow_run' && 'true' || 'false' }}
- name: Maintain dependency store budget
shell: bash
env:
# The current store is well below this ceiling. Prune only after
# accumulated retired package versions exceed 8 GiB.
OPENCLAW_PNPM_STORE_MAX_KIB: "8388608"
run: |
set -euo pipefail
store_dir="${PNPM_CONFIG_STORE_DIR:?}"
before_kib="$(du -sk "$store_dir" | cut -f1)"
after_kib="$before_kib"
pruned=false
if [ "$before_kib" -gt "$OPENCLAW_PNPM_STORE_MAX_KIB" ]; then
echo "pnpm store is ${before_kib} KiB; pruning above ${OPENCLAW_PNPM_STORE_MAX_KIB} KiB ceiling"
PNPM_CONFIG_STORE_DIR="$store_dir" pnpm store prune
after_kib="$(du -sk "$store_dir" | cut -f1)"
pruned=true
else
echo "pnpm store is ${before_kib} KiB; below ${OPENCLAW_PNPM_STORE_MAX_KIB} KiB ceiling"
fi
{
echo "### Dependency store maintenance"
echo
echo "- Before: ${before_kib} KiB"
echo "- After: ${after_kib} KiB"
echo "- Pruned: ${pruned}"
} >> "$GITHUB_STEP_SUMMARY"
if [ "$after_kib" -gt "$OPENCLAW_PNPM_STORE_MAX_KIB" ]; then
echo "::warning::pnpm store remains above its 8 GiB maintenance ceiling after prune"
fi
save-actions-cache: "true"
save-node-compile-cache: "true"
save-vitest-fs-cache: "true"
sticky-disk: "false"
use-actions-cache: "true"
vitest-fs-cache: "true"
- name: Select broad cache seed
if: github.event_name != 'workflow_run'
shell: bash
run: |
set -euo pipefail
@@ -113,5 +65,4 @@ jobs:
EOF
- name: Warm transform and compile caches
if: github.event_name != 'workflow_run'
run: node scripts/ci-run-node-test-shard.mjs
@@ -1536,17 +1536,19 @@ struct ChatViewModelOutboxTests {
await transport.state.setStaleHistoryRows([])
let vm = await makeOutboxViewModel(transport: transport, outbox: store)
await MainActor.run { vm.load() }
try await waitUntil("reconnect history request") {
await transport.state.historyRequestCount >= 1
try await waitUntil("bootstrap history settles") {
let bootstrapComplete = await MainActor.run { !vm.isLoading }
let requestCount = await transport.state.historyRequestCount
return bootstrapComplete && requestCount >= 1
}
try await Task.sleep(nanoseconds: 200_000_000)
let settledRequestCount = await transport.state.historyRequestCount
try await Task.sleep(nanoseconds: 200_000_000)
// A recursive refresh starts immediately after applying stale history.
// Observe beyond bootstrap completion instead of sampling between its
// ordinary history request and the health-triggered reconciliation.
try await Task.sleep(nanoseconds: 1_000_000_000)
// load() also owns its ordinary visible-session history request. The
// exact count can be one or two; neither may trigger a recursive pass.
#expect(settledRequestCount <= 2)
#expect(await transport.state.historyRequestCount == settledRequestCount)
#expect(await transport.state.historyRequestCount <= 2)
#expect(await store.loadCommands().map(\.status) == [.awaitingConfirmation])
}
+7 -7
View File
@@ -28,9 +28,9 @@ dispatch.
| Job | Purpose | When it runs |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| `preflight` | Detect docs-only changes, changed scopes, changed extensions, and build the CI manifest | Always on non-draft pushes and PRs |
| `preflight` | Detect changed scopes and build the CI manifest; on canonical Node-relevant `main`, refresh and maintain the dependency snapshot before fanout | Always on non-draft pushes and PRs |
| `security-fast` | Private key detection, changed-workflow audit via `zizmor`, and production lockfile audit | Always on non-draft pushes and PRs |
| `pnpm-store-warmup` | Warm the lockfile-pinned pnpm store cache without blocking Linux Node shards | Node or docs-check lanes selected |
| `pnpm-store-warmup` | Warm the lockfile-pinned Actions cache for pull requests and manual runs without blocking Linux Node shards | Node or docs-check lanes selected outside main |
| `build-artifacts` | Build `dist/`, Control UI, built-CLI smoke checks, startup memory, and embedded built-artifact checks | Node-relevant changes |
| `control-ui-i18n` | Verify generated Control UI locale bundles, metadata, and translation memory; advisory on automatic runs, blocking on manual release CI | Control UI i18n-relevant changes and manual CI |
| `checks-fast-core` | Fast Linux correctness lanes: suppression-baseline max-lines ratchet, bundled + protocol, Bun launcher, and the CI-routing fast task | Node-relevant changes |
@@ -57,7 +57,7 @@ Standalone Periphery workflows enforce zero dead-code findings for the iOS and m
## Fail-fast order
1. `preflight` decides which lanes exist at all. The `docs-scope` and `changed-scope` logic are steps inside this job, not standalone jobs. Canonical `main` starts immediately, but its concurrency group admits only one complete run and coalesces later pushes into one newest pending run.
1. `preflight` decides which lanes exist at all. The `docs-scope` and `changed-scope` logic are steps inside this job, not standalone jobs. Canonical `main` starts immediately, but its concurrency group admits only one complete run and coalesces later pushes into one newest pending run. Node-relevant main pushes also serialize the sole dependency-disk writer and its size maintenance here before downstream jobs may mount the key; Blacksmith may expose a fresh commit only to a later workflow run, so same-run consumers retain the marker-checked local fallback.
2. `security-fast`, `check-*`, `check-additional-*`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs.
3. `build-artifacts` and the advisory `control-ui-i18n` check overlap with the fast Linux lanes. Source PRs exclude generated locale snapshots; the standalone refresh workflow repairs and auto-merges an isolated generated PR in the background. Canonical `release/YYYY.M.PATCH` branches may include release-prep locale repairs with the other generated release output.
4. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-plugins-*`, `checks-fast-contracts-channels-*`, `checks-node-*`, `checks-windows`, `macos-node`, `macos-swift`, `ios-build`, and `android`.
@@ -115,9 +115,9 @@ The slowest Node test families are split or balanced so each job stays small wit
- Pull requests on the canonical repository reuse the changed-test resolver against the synthetic merged-tree diff. Precise changes run one targeted Node job; each selected test file gets its own process so stateful suite isolation remains intact. The planner combines sibling tests with import-graph dependents and falls back to the existing 14-job compact full-suite plan for workspace package, package/lockfile, shared harness, split-config, renamed, or deleted changes, public extension-contract changes, tests with special shard setup, partially resolved or empty targets, oversized path or target plans, and planner errors. Targeted plans always retain the full built-artifact boundary gate because its repository scanners cannot be derived from imports. `main` pushes run the same full compact suite: pending intermediate push events can be coalesced, so the newest surviving run must validate the complete integration tree rather than only its final single-push diff. Manual dispatches and release gates retain the full named per-shard matrix.
- The full Node matrix admits the consistently slow serial tooling, auto-reply command shards, and broad core-fast cache writer first. This keeps the 28-job cap while preventing critical-path work and the next run's transform seed from slipping into a later wave.
- Broad browser, QA, media, and miscellaneous plugin tests use their dedicated Vitest configs instead of the shared plugin catch-all. Include-pattern shards record timing entries using the CI shard name, so `.artifacts/vitest-shard-timings.json` can distinguish a whole config from a filtered shard.
- Linux Node shard jobs persist Vitest's experimental filesystem module cache. Trusted Blacksmith jobs clone one protected disk per platform and Node line; pull requests write only to their private clone and discard it, so PR traffic cannot allocate backing disks or publish feature-branch transforms. GitHub-hosted and fork jobs use an `actions/cache` fallback with coarse PR-scoped restore prefixes. The planner marks the broad `core-unit-fast` graph as the single writer without coupling cache ownership to matrix order. Concurrent Vitest workers retain separate live directories. A transform-input fingerprint clears incompatible lockfile, package, tsconfig, and Vitest-config generations inside the stable disk. Only a protected writer scans and prunes the cache to 75% after it exceeds 2 GiB. A non-cancelling daily or default-branch repository-dispatch warmer refreshes the protected seed; GitHub's normal cache eviction expires fallback PR archives.
- Trusted Linux Node jobs also bind the pnpm store and `node_modules` from one protected dependency disk per supported Node line. Package manifests, install settings, runner platform, and the exact Node patch stay out of the disk key; an exact runtime and install-input fingerprint decides whether a job reuses the tree or reinstalls and refreshes the same disk. Manifests are canonicalized before hashing. The audited direct root hooks retain only pnpm's install lifecycle scripts, so formatting and ordinary test/build script edits keep the warm dependency tree; unaudited lifecycle-hook drift fails closed until its source inputs join the fingerprint contract. Dependency, package-manager, hook-source, and lockfile changes always invalidate the snapshot. A pull request whose read-only snapshot has a different fingerprint detaches the workspace bind and installs into runner-local storage, avoiding slow writes to a clone it cannot publish. Sticky cold installs disable pnpm's inner fetch retries and make up to three bounded full-install attempts from the progressively warmed store; a timeout remains a failure. After an exact restore or frozen-lockfile install, setup disables pnpm's redundant pre-run dependency check: the repository intentionally prunes plugin-local `node_modules`, which pnpm otherwise treats as stale and repairs through unsafe concurrent implicit installs during shard fanout. The non-cancelled cache warmer is the only writer: successful `main` CI completion coalesces a dependency refresh, while the daily run remains a deadline fallback. That writer measures the store on every refresh and runs `pnpm store prune` only after retired package versions push it above 8 GiB. Required CI jobs and pull requests get disposable clones, so dependency changes do not create new disks, competing snapshots, or a cache lock that can cancel builds.
- Node shard and build-artifact jobs also restore Node's portable on-disk compile cache. Independent `test` and `build` namespaces prevent their writers from replacing each other's snapshots: the scheduled test warmer owns the protected test seed, while `build-artifacts` publishes the protected build seed only from trusted `main` pushes. PR jobs read protected snapshots without publishing feature-branch bytecode; fallback archives remain PR-scoped. This reuses V8 bytecode for Node-loaded orchestration, build tooling, and external dependencies across different checkout paths, including when only part of the source graph changes. Vitest child processes disable an inherited compile cache because coverage can be enabled inside dynamic configs and V8 coverage can lose source-position precision when scripts are deserialized from bytecode.
- Linux Node shard jobs persist Vitest's experimental filesystem module cache through the upstream Actions cache API, which Blacksmith transparently accelerates on its runners. Every CI shard is restore-only and unpacks the protected seed into its own runner-local root; the shard wrapper then gives concurrent Vitest processes separate live subdirectories. Only the non-cancelling daily or explicitly dispatched warmer saves a new immutable archive, so pull requests cannot publish transforms or mint per-PR cache families. A transform-input fingerprint clears incompatible lockfile, package, tsconfig, and Vitest-config generations. The protected writer scans and prunes its restored cache to 75% after it exceeds 2 GiB. Vitest hashes module id, source content, environment, and resolved transform config, so ordinary partial source changes keep unchanged entries warm while changed modules miss safely. Coarse restore prefixes bridge workflow runs; normal Actions cache LRU and inactivity eviction bound old immutable archives.
- Trusted Linux Node jobs also bind the pnpm store and `node_modules` from one protected dependency disk per supported Node line. Package manifests, install settings, runner platform, and the exact Node patch stay out of the disk key; an exact runtime and install-input fingerprint decides whether a job reuses the tree or reinstalls and refreshes the same disk. Manifests are canonicalized before hashing. The audited direct root hooks retain only pnpm's install lifecycle scripts, so formatting and ordinary test/build script edits keep the warm dependency tree; unaudited lifecycle-hook drift fails closed until its source inputs join the fingerprint contract. Dependency, package-manager, hook-source, and lockfile changes always invalidate the snapshot. A pull request whose read-only snapshot has a different fingerprint detaches the workspace bind and installs into runner-local storage, avoiding slow writes to a clone it cannot publish. Sticky cold installs disable pnpm's inner fetch retries and make up to three bounded full-install attempts from the progressively warmed store; a timeout remains a failure. After an exact restore or frozen-lockfile install, setup disables pnpm's redundant pre-run dependency check: the repository intentionally prunes plugin-local `node_modules`, which pnpm otherwise treats as stale and repairs through unsafe concurrent implicit installs during shard fanout. Canonical main preflight is the sole writer and measures the store on every refresh, running `pnpm store prune` only after retired package versions push it above 8 GiB. Blacksmith snapshot publication is asynchronous even after a writer job completes, so the first run after a fresh key or fingerprint can remain cold; later exact-marker restores are the rollout proof. Required CI jobs and pull requests get disposable clones, so dependency changes do not create new disks, competing snapshots, or a cache lock that can cancel builds.
- Node shard and build-artifact jobs also restore Node's portable on-disk compile cache through immutable Actions caches. Independent `test` and `build` namespaces prevent their writers from replacing each other's archives: the scheduled test warmer owns the protected test seed, while `build-artifacts` may publish at most one protected build archive per UTC day from trusted `main` pushes. PR and ordinary test jobs only read protected snapshots, so feature-branch bytecode never enters the shared seed and PR traffic creates no cache archives. This reuses V8 bytecode for Node-loaded orchestration, build tooling, and external dependencies across different checkout paths, including when only part of the source graph changes. Vitest child processes disable an inherited compile cache because coverage can be enabled inside dynamic configs and V8 coverage can lose source-position precision when scripts are deserialized from bytecode.
- The build-artifact job also persists content-fingerprinted `build-all` step outputs. CI's self-built plugin SDK declarations hash the complete repository-owned TypeScript/JSON source graph, exclude installed and generated directories, and restore both flat declarations and package bridges after `tsdown` clears `dist`. Documentation, workflow, plugin, and other changes outside that graph can reuse the declaration snapshot; source changes rebuild it before the export gate runs.
- Full declaration builds split `tsdown` into AI, workspace-package, and unified groups. Each group caches declarations only, then still rebuilds runtime JavaScript before restoring those declarations. Core or plugin changes therefore invalidate only the large unified graph, while workspace-package changes conservatively invalidate every dependent declaration group. Public full builds generally use an immutable Actions cache; coarse restore keys seed partial changes, per-group content fingerprints reject stale data, and GitHub's cache quota evicts old generations. The weekly Node 22 lane instead publishes a 14-day artifact after successful `main` runs and restores only artifacts whose immutable producer identity resolves to that workflow on `main`, avoiding quota churn without allowing PR code to write a shared cache. Private-QA declarations are never persisted in Actions caches because cache namespaces are not confidentiality boundaries.
- `check-additional-*` stripes the supplemental boundary guard list (`scripts/run-additional-boundary-checks.mjs`) into one prompt-heavy shard (`check-additional-boundaries-a`, which includes the Codex prompt snapshot drift check) and one combined shard for the remaining stripes (`check-additional-boundaries-bcd`), each running independent guards concurrently and printing per-check timings. Package-boundary compile/canary work stays together, and runtime topology architecture runs separately from the gateway watch coverage embedded in `build-artifacts`.
@@ -131,7 +131,7 @@ job budget.
Android CI runs both `testPlayDebugUnitTest` and `testThirdPartyDebugUnitTest` and then builds the Play debug APK. The third-party flavor has no separate source set or manifest; its unit-test lane still compiles the flavor with the SMS/call-log BuildConfig flags, while avoiding a duplicate debug APK packaging job on every Android-relevant push. Each current Gradle task has one protected sticky disk; PR jobs use disposable clones, while protected runs refresh content-addressed Gradle entries in place.
Blacksmith sticky-disk keys are deliberately bounded by supported runtime or task dimensions, never PR number, commit, run, branch, or dependency hash. After a key-version migration, add only the exact obsolete key, architecture, and region identities to `.github/retired-sticky-disks.json`, dispatch `Sticky Disk Cleanup` from `main` with the same dimensions and confirmation, verify deletion, then remove those entries. The workflow routes ARM identities to an ARM runner, rejects runner-region mismatches, uses Blacksmith's exact-key deletion action, and never deletes Docker builder caches or wildcard prefixes. GitHub Actions fallback archives use GitHub's normal cache eviction.
Blacksmith sticky-disk keys are deliberately bounded by supported runtime or task dimensions, never PR number, commit, run, branch, or dependency hash. Runtime transform and compile caches use Actions cache instead of sticky disks because immutable archives expose verifiable restore/save results and avoid mutable snapshot-promotion failures. After a sticky key-version migration, add only the exact obsolete key, architecture, and region identities to `.github/retired-sticky-disks.json`, dispatch `Sticky Disk Cleanup` from `main` with the same dimensions and confirmation, verify deletion, then remove those entries. The workflow routes ARM identities to an ARM runner, rejects runner-region mismatches, uses Blacksmith's exact-key deletion action, and never deletes Docker builder caches or wildcard prefixes. Actions cache archives use normal LRU and inactivity eviction.
The `check-dependencies` shard runs production Knip dependency, unused-file, and unused-export checks. The unused-file guard fails when a PR adds a new unreviewed unused file or leaves a stale allowlist entry, while preserving intentional dynamic plugin, generated, build, live-test, and package bridge surfaces that Knip cannot resolve statically. The unused-export guard excludes test-support files and fails on every unused production export; intentional dynamic consumers must be modeled in `config/knip.config.ts`. Historical targets run the export guard when they provide it and retain their older dead-code fallback otherwise.
+83 -80
View File
@@ -46,6 +46,7 @@ const MATURITY_GENERATED_PR_PATHS = [
type WorkflowStep = {
env?: Record<string, unknown>;
if?: string;
name?: string;
run?: string;
uses?: string;
@@ -1896,7 +1897,7 @@ describe("ci workflow guards", () => {
const runsOn = (job as { "runs-on"?: unknown })["runs-on"];
return typeof runsOn === "string" && runsOn.includes("blacksmith-");
});
const stickyConsumers = Object.entries(workflow.jobs).flatMap(([jobName, job]) => {
const stickySteps = Object.entries(workflow.jobs).flatMap(([jobName, job]) => {
const steps = (job as { steps?: WorkflowStep[] }).steps ?? [];
return steps.flatMap((step) => {
const stepWith = step.with;
@@ -1906,6 +1907,8 @@ describe("ci workflow guards", () => {
return [{ jobName, stepWith }];
});
});
const preflightWriter = stickySteps.find((entry) => entry.jobName === "preflight");
const stickyConsumers = stickySteps.filter((entry) => entry.jobName !== "preflight");
// Every Linux Blacksmith lane that installs Node dependencies consumes
// the snapshot; missing entries silently pay the full install again.
expect(stickyConsumers.map((entry) => entry.jobName).toSorted()).toEqual([
@@ -1940,6 +1943,32 @@ describe("ci workflow guards", () => {
for (const { jobName, stepWith } of stickyConsumers) {
expect(stepWith["save-sticky-disk"], jobName).toBeUndefined();
}
expect(preflightWriter?.stepWith).toMatchObject({
"save-sticky-disk": "true",
"sticky-disk": "true",
"use-actions-cache": "false",
});
const preflightSteps = workflow.jobs.preflight.steps as WorkflowStep[];
const refreshStep = preflightSteps.find(
(step: WorkflowStep) => step.name === "Refresh sticky dependency snapshot",
)!;
const maintainStep = preflightSteps.find(
(step: WorkflowStep) => step.name === "Maintain sticky dependency store budget",
)!;
expect(refreshStep.if).toContain("github.event_name == 'push'");
expect(refreshStep.if).toContain("github.repository == 'openclaw/openclaw'");
expect(refreshStep.if).toContain("github.ref == 'refs/heads/main'");
expect(refreshStep.if).toContain("steps.manifest.outputs.run_node == 'true'");
expect(maintainStep.if).toBe(refreshStep.if);
expect(preflightSteps.indexOf(refreshStep)).toBeLessThan(preflightSteps.indexOf(maintainStep));
expect(maintainStep.env?.OPENCLAW_PNPM_STORE_MAX_KIB).toBe("8388608");
expect(maintainStep.run).toContain('store_dir="${PNPM_CONFIG_STORE_DIR:?}"');
expect(maintainStep.run).toContain('PNPM_CONFIG_STORE_DIR="$store_dir" pnpm store prune');
expect(maintainStep.run).toContain('>> "$GITHUB_STEP_SUMMARY"');
expect(workflow.jobs["pnpm-store-warmup"].if).toContain("github.ref == 'refs/heads/main'");
expect(workflow.jobs["pnpm-store-warmup"].if).toContain(
"github.repository == 'openclaw/openclaw'",
);
// Current sticky consumers all use the single supported Node line. A
// planner-provided version would silently create a writerless disk.
for (const { jobName, stepWith } of stickyConsumers) {
@@ -1954,26 +1983,17 @@ describe("ci workflow guards", () => {
expect(stepWith["sticky-disk"], jobName).toContain(
"matrix.node_version == null || matrix.node_version == '24.x'",
);
expect(stepWith["runtime-cache-sticky-disk"], jobName).toContain(
"matrix.node_version == null || matrix.node_version == '24.x'",
);
}
}
const warmWorkflow = parse(readFileSync(".github/workflows/vitest-cache-warm.yml", "utf8"));
const warmSetupStep = warmWorkflow.jobs.warm.steps.find(
(step: WorkflowStep) => step.name === "Setup Node environment",
);
expect(warmSetupStep.with["save-sticky-disk"]).toBe("true");
expect(warmSetupStep.with["save-sticky-disk"]).toBeUndefined();
expect(warmSetupStep.with["sticky-disk"]).toBe("false");
expect(warmWorkflow.on).not.toHaveProperty("pull_request");
expect(warmWorkflow.on).not.toHaveProperty("workflow_dispatch");
expect(warmWorkflow.on.workflow_run).toMatchObject({
workflows: ["CI"],
branches: ["main"],
types: ["completed"],
});
expect(warmWorkflow.jobs.warm.if).toContain(
"github.event.workflow_run.conclusion == 'success'",
);
expect(warmWorkflow.on).not.toHaveProperty("workflow_run");
const action = parse(readFileSync(".github/actions/setup-node-env/action.yml", "utf8"));
const validateLayoutStep = action.runs.steps.find(
(step: WorkflowStep) => step.name === "Validate sticky pnpm layout",
@@ -2021,7 +2041,7 @@ describe("ci workflow guards", () => {
);
expect(mountStep).toMatchObject({
if: "inputs.sticky-disk == 'true'",
uses: "useblacksmith/stickydisk@5b350170ae4ef55b536b548ef5f5896e76a6b54f",
uses: "useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494",
with: {
path: "/var/tmp/openclaw-node-deps",
},
@@ -2030,7 +2050,7 @@ describe("ci workflow guards", () => {
// per-PR/per-manifest-hash keys saturated that cap. Install inputs and exact
// runtime patches belong in the marker, not the backing-disk key.
expect(mountStep.with.key).toBe(
"${{ github.repository }}-node-deps-bind-v4-${{ inputs.node-version }}",
"${{ github.repository }}-node-deps-bind-v5-${{ inputs.node-version }}",
);
expect(mountStep.with.commit).toBe(
"${{ inputs.save-sticky-disk == 'true' && github.event_name != 'pull_request' && 'true' || 'false' }}",
@@ -2364,16 +2384,13 @@ describe("ci workflow guards", () => {
}
});
it("persists isolated transform and compile caches with one semantic writer", () => {
it("persists isolated transform and compile caches through immutable protected archives", () => {
const workflow = readCiWorkflow();
const nodeTestJob = workflow.jobs["checks-node-core-test-nondist-shard"];
const setupNodeStep = nodeTestJob.steps.find(
(step: WorkflowStep) => step.name === "Setup Node environment",
);
const action = parse(readFileSync(".github/actions/setup-node-env/action.yml", "utf8"));
const stickyStep = action.runs.steps.find(
(step: WorkflowStep) => step.name === "Mount Vitest transform cache sticky disk",
);
const writerStep = action.runs.steps.find(
(step: WorkflowStep) => step.name === "Restore and save Vitest transform cache",
);
@@ -2383,8 +2400,8 @@ describe("ci workflow guards", () => {
const configureStep = action.runs.steps.find(
(step: WorkflowStep) => step.name === "Configure Vitest transform cache",
);
const compileStickyStep = action.runs.steps.find(
(step: WorkflowStep) => step.name === "Mount Node compile cache sticky disk",
const compileEpochStep = action.runs.steps.find(
(step: WorkflowStep) => step.name === "Select Node compile cache epoch",
);
const compileWriterStep = action.runs.steps.find(
(step: WorkflowStep) => step.name === "Restore and save Node compile cache",
@@ -2406,68 +2423,61 @@ describe("ci workflow guards", () => {
"node-compile-cache": "true",
"node-compile-cache-scope": "test",
"vitest-fs-cache": "true",
"save-node-compile-cache": "${{ matrix.save_vitest_fs_cache && 'true' || 'false' }}",
"save-vitest-fs-cache": "${{ matrix.save_vitest_fs_cache && 'true' || 'false' }}",
});
expect(setupNodeStep.with).not.toHaveProperty("save-node-compile-cache");
expect(setupNodeStep.with).not.toHaveProperty("save-vitest-fs-cache");
expect(setupNodeStep.with).not.toHaveProperty("runtime-cache-sticky-disk");
expect(action.inputs).not.toHaveProperty("runtime-cache-sticky-disk");
expect(action.inputs["vitest-fs-cache"].default).toBe("false");
expect(action.inputs["save-vitest-fs-cache"].default).toBe("false");
expect(action.inputs["node-compile-cache"].default).toBe("false");
expect(action.inputs["node-compile-cache-scope"].default).toBe("test");
expect(action.inputs["save-node-compile-cache"].default).toBe("false");
// O(1) disks: the old per-PR overlay minted a backing disk per PR and
// helped exhaust Blacksmith's installation-wide sticky-disk budget.
// Content-hash entry keys make cross-PR sharing safe by construction, so
// every PR reads the one protected snapshot instead.
expect(
action.runs.steps.some(
(step: WorkflowStep) => step.name === "Mount protected Vitest transform seed",
action.runs.steps.some((step: WorkflowStep) =>
step.name?.includes("transform cache sticky disk"),
),
).toBe(false);
expect(
action.runs.steps.some((step: WorkflowStep) =>
step.name?.includes("compile cache sticky disk"),
),
).toBe(false);
expect(stickyStep).toMatchObject({
uses: "useblacksmith/stickydisk@5b350170ae4ef55b536b548ef5f5896e76a6b54f",
with: {
key: "${{ github.repository }}-vitest-fs-v2-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}",
path: "/var/tmp/openclaw-vitest-fs-cache",
// Single semantic writer: PR mounts never commit the shared snapshot.
commit:
"${{ inputs.save-vitest-fs-cache == 'true' && github.event_name != 'pull_request' && 'true' || 'false' }}",
},
});
expect(stickyStep.if).toContain("inputs.sticky-disk == 'true'");
expect(stickyStep.with.key).not.toContain("pull_request");
expect(stickyStep.with.key).not.toContain("hashFiles");
expect(writerStep.uses).toBe("actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae");
expect(writerStep.if).toContain("inputs.save-vitest-fs-cache == 'true'");
expect(writerStep.with.key).toContain("vitest-fs-v3-protected-");
expect(writerStep.with.key).toContain("github.run_id");
expect(writerStep.with.key).toContain("github.run_attempt");
expect(writerStep.with.key).not.toContain("pull_request");
expect(writerStep.with["restore-keys"]).toContain("**/tsconfig*.json");
expect(writerStep.with.key).toContain("!**/node_modules/**");
expect(writerStep.with["restore-keys"]).toContain("!**/node_modules/**");
expect(readerStep.uses).toBe(CACHE_V5);
expect(readerStep.if).toContain("inputs.save-vitest-fs-cache != 'true'");
expect(readerStep.with["restore-keys"]).toBe(writerStep.with["restore-keys"]);
expect(readerStep.with.key).toContain("!**/node_modules/**");
expect(configureStep.env.CACHE_GENERATION).toContain("!**/node_modules/**");
expect(configureStep.run).toContain("OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=$cache_root");
expect(configureStep.run).toContain(".openclaw-transform-generation");
expect(configureStep.run).not.toContain("protected Vitest transform seed");
expect(configureStep.env.CACHE_WRITER).toBe(
"${{ inputs.save-vitest-fs-cache == 'true' && ((inputs.sticky-disk != 'true' && inputs.runtime-cache-sticky-disk != 'true') || github.event_name != 'pull_request') && '1' || '0' }}",
"${{ inputs.save-vitest-fs-cache == 'true' && '1' || '0' }}",
);
expect(configureStep.run).toContain("OPENCLAW_VITEST_FS_MODULE_CACHE_WRITER=");
// Prune work on a read-only sticky PR mount is discarded with the clone.
expect(configureStep.env.CACHE_WRITER).toContain("github.event_name != 'pull_request'");
expect(compileStickyStep.with).toMatchObject({
path: "/var/tmp/openclaw-node-compile-cache",
commit:
"${{ inputs.save-node-compile-cache == 'true' && github.event_name != 'pull_request' && 'true' || 'false' }}",
});
expect(compileStickyStep.with.key).toContain(
"node-compile-v2-${{ inputs.node-compile-cache-scope }}-protected-",
);
expect(compileEpochStep.run).toContain('if [ "$CACHE_SCOPE" = "build" ]');
expect(compileEpochStep.run).toContain("date -u +%Y%m%d");
expect(compileEpochStep.run).toContain("GITHUB_RUN_ID");
expect(compileWriterStep.with.key).toContain(
"node-compile-v2-${{ inputs.node-compile-cache-scope }}-",
"node-compile-v3-${{ inputs.node-compile-cache-scope }}-protected-",
);
expect(compileWriterStep.with.key).toContain("github.run_attempt");
expect(compileWriterStep.with.key).toContain("steps.node-compile-cache-epoch.outputs.value");
expect(compileWriterStep.with.key).not.toContain("pull_request");
expect(compileReaderStep.with["restore-keys"]).toBe(compileWriterStep.with["restore-keys"]);
expect(compileConfigureStep.run).toContain("NODE_COMPILE_CACHE=$cache_root");
expect(compileConfigureStep.run).toContain("NODE_COMPILE_CACHE_PORTABLE=1");
expect(compileConfigureStep.env.CACHE_WRITER).toBe(
"${{ inputs.save-node-compile-cache == 'true' && '1' || '0' }}",
);
expect(buildSetupNodeStep.with).toMatchObject({
"node-compile-cache": "true",
"node-compile-cache-scope": "build",
@@ -2488,6 +2498,7 @@ describe("ci workflow guards", () => {
it("warms protected caches without main-run cancellation", () => {
const warmerSource = readFileSync(".github/workflows/vitest-cache-warm.yml", "utf8");
const warmer = parse(warmerSource);
const workflow = readCiWorkflow();
const warmerSetup = warmer.jobs.warm.steps.find(
(step: WorkflowStep) => step.name === "Setup Node environment",
);
@@ -2503,49 +2514,41 @@ describe("ci workflow guards", () => {
const maintainStoreStep = warmer.jobs.warm.steps.find(
(step: WorkflowStep) => step.name === "Maintain dependency store budget",
);
const maintainStickyStoreStep = workflow.jobs.preflight.steps.find(
(step: WorkflowStep) => step.name === "Maintain sticky dependency store budget",
)!;
expect(warmer.concurrency["cancel-in-progress"]).toBe(false);
expect(warmer.concurrency.group).toBe("vitest-cache-warm");
expect(warmer.on.workflow_dispatch).toBeUndefined();
expect(warmer.on.repository_dispatch.types).toEqual(["vitest-cache-warm"]);
expect(warmer.jobs.warm.if).toContain("github.repository == 'openclaw/openclaw'");
expect(warmer.jobs.warm.if).toContain("github.event.workflow_run.conclusion == 'success'");
expect(warmer.jobs.warm.if).not.toContain("cancelled");
expect(checkoutStep.with.ref).toBe(
"${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}",
);
expect(warmer.on).not.toHaveProperty("workflow_run");
expect(checkoutStep.with).toBeUndefined();
expect(warmerSource).toContain('cron: "17 8 * * *"');
expect(warmerSource).toContain('candidate.shardName.startsWith("core-unit-fast")');
expect(warmerSetup.with).toMatchObject({
"node-compile-cache-scope": "test",
"save-sticky-disk": "true",
"sticky-disk": "true",
"save-actions-cache": "true",
"save-node-compile-cache": "true",
"save-vitest-fs-cache": "true",
"sticky-disk": "false",
"use-actions-cache": "true",
});
// The per-PR cache layer is gone, so its close-time cleanup workflow must
// stay deleted; GitHub's own LRU/TTL eviction handles PR-ref archives.
// CI is restore-only, so no per-PR runtime cache family or close-time
// cleanup workflow exists. Actions cache LRU/TTL expires old warmers.
expect(existsSync(".github/workflows/pr-cache-cleanup.yml")).toBe(false);
expect(warmerSetup.with["save-node-compile-cache"]).toContain(
"github.event_name != 'workflow_run'",
);
expect(warmerSetup.with["save-vitest-fs-cache"]).toContain(
"github.event_name != 'workflow_run'",
);
expect(seedStep.if).toBe("github.event_name != 'workflow_run'");
expect(warmStep.if).toBe("github.event_name != 'workflow_run'");
expect(warmer.jobs.warm.steps.indexOf(warmerSetup)).toBeLessThan(
warmer.jobs.warm.steps.indexOf(maintainStoreStep),
);
expect(maintainStoreStep.env.OPENCLAW_PNPM_STORE_MAX_KIB).toBe("8388608");
expect(maintainStoreStep.run).toContain('store_dir="${PNPM_CONFIG_STORE_DIR:?}"');
expect(maintainStoreStep.run).toContain('PNPM_CONFIG_STORE_DIR="$store_dir" pnpm store prune');
expect(maintainStoreStep.run).toContain('>> "$GITHUB_STEP_SUMMARY"');
expect(seedStep.if).toBeUndefined();
expect(warmStep.if).toBeUndefined();
expect(maintainStoreStep).toBeUndefined();
expect(maintainStickyStoreStep.env.OPENCLAW_PNPM_STORE_MAX_KIB).toBe("8388608");
const maintenanceRoot = mkdtempSync(path.join(tmpdir(), "openclaw-pnpm-maintenance-"));
try {
const storeDir = path.join(maintenanceRoot, "store");
const summaryPath = path.join(maintenanceRoot, "summary.md");
mkdirSync(storeDir);
const result = spawnSync("bash", ["-c", maintainStoreStep.run], {
const result = spawnSync("bash", ["-c", maintainStickyStoreStep.run], {
encoding: "utf8",
env: {
...process.env,