From f69cb2f75e499aa7483b3fa01cd849cee7ec1220 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Jul 2026 00:38:50 -0700 Subject: [PATCH] perf(ci): serve node_modules snapshots from O(1) protected sticky disks (#109752) * perf(ci): serve node_modules snapshots from O(1) protected sticky disks Every Blacksmith mount of the dependency sticky disk currently 429s: the v2 key minted one backing disk per PR and per manifest hash, which saturated Blacksmith's installation-wide 1000-sticky-disk budget (run 29559333389, job checks-node-compact-small-15: "Sticky disk limit exceeded"). Same-repo shards then fall back to a cold, storeless pnpm install (~40s) on every run - worse than the ~22s actions/cache path the sticky rollout replaced. Re-key the snapshot to one stable disk per node-version and move the install inputs into a runtime fingerprint marker: - Key is now `-node-deps-bind-v3-`; dependency changes refresh the disk in place instead of allocating a new one. - The fingerprint (manifest hashFiles set + node version + lockfile mode) is evaluated on the bind step, before the mount lands, so the '**/package.json' glob cannot sweep snapshot-internal manifests. - Consumers mount read-only (commit: false); on fingerprint match they restore importer archives and skip pnpm install entirely, on mismatch they install against the clone's on-disk store (warm store, no actions/cache download) without capturing. - Writers are trusted non-PR jobs only: build-artifacts on canonical pushes plus the scheduled vitest-cache-warm run as a deadline writer (main pushes cancel each other under merge traffic and would starve the snapshot). commit: on-change keeps warm no-op runs from committing. - Roll the consumer flag out to every Linux Blacksmith lane that installs dependencies (build-artifacts, check-shard, check-additional-shard, check-docs, checks-ui, control-ui-i18n, native-i18n, qa-smoke-ci-profile, checks-fast-core, both contract shards) with the same fork/dispatch gates as the nondist shard. Guards now enumerate the consumer set, pin the O(1) key shape, enforce single-writer commit expressions, and cover the fingerprint marker in the importer capture/restore helper. Verified locally: importer archive capture 0.96s/228K and restore 0.07s against the real 2.0GB hoisted tree; a snapshot untarred into a fresh workspace resolves modules, runs bin shims, `pnpm exec`, and a Vitest suite (one-time pnpm reconcile only when the absolute path changes, which Blacksmith's fixed /home/runner/_work layout avoids). * fix(ci): commit dependency snapshots explicitly, not via on-change heuristic stickydisk's on-change mode compares allocated disk bytes with a 4KB threshold, so a fingerprint refresh whose reinstall keeps usage stable (metadata-only manifest edits, same-sized dependency swaps) could be silently discarded, stranding every consumer on a stale marker and a permanent reinstall path. Writers now commit explicitly, mirroring the Vitest transform disk's rationale; warm no-op writer runs re-commit an identical snapshot, which is cheap and safe. Also document that the non-PR commit gate binds cooperating code only - the enforced trust boundary stays the fork/dispatch runner gate, matching the protected node-compile disk's posture. * test(ci): guard sticky consumers against writerless node-version key splits Reviewer follow-up: the snapshot key is partitioned by node-version and both writers (build-artifacts, vitest-cache-warm) rely on the action default, so a consumer pinning any other version would split onto a key nobody seeds and silently regress to permanently cold installs. Pin the action default to 24.x and assert every sticky consumer resolves to that same key segment. * test: narrow sticky-consumer step.with before indexing The filter's runtime narrowing did not carry into the map callback, so step.with indexing failed check-test-types (TS18048). Collect a narrowed stepWith instead. --- .github/actions/setup-node-env/action.yml | 68 +++++++-- .../setup-node-env/sticky-importers.sh | 7 +- .github/workflows/ci.yml | 59 ++++++++ .github/workflows/vitest-cache-warm.yml | 5 + test/scripts/ci-workflow-guards.test.ts | 133 ++++++++++++++---- 5 files changed, 233 insertions(+), 39 deletions(-) diff --git a/.github/actions/setup-node-env/action.yml b/.github/actions/setup-node-env/action.yml index 31cd9ba8a14e..ab0179fb9ecc 100644 --- a/.github/actions/setup-node-env/action.yml +++ b/.github/actions/setup-node-env/action.yml @@ -35,6 +35,14 @@ inputs: alongside. required: false default: "false" + save-sticky-disk: + description: > + Whether this job may commit the shared dependency snapshot. One + designated job per node-version key; only honored outside pull_request + events so feature-branch installs can never publish a protected + snapshot. + required: false + default: "false" runtime-cache-sticky-disk: description: Whether runtime caches should use Blacksmith sticky disks instead of actions/cache. required: false @@ -116,14 +124,25 @@ runs: if: inputs.sticky-disk == 'true' uses: useblacksmith/stickydisk@5b350170ae4ef55b536b548ef5f5896e76a6b54f # v1.4.0 with: - # The key covers every dependency and lifecycle input. PR scope keeps - # feature snapshots separate from protected pushes. The v2 marker lets - # exact warm hits trust the snapshotted stock layout without rescanning. - key: ${{ github.repository }}-node-deps-bind-v2-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'protected' }}-${{ inputs.node-version }}-frozen-${{ inputs.frozen-lockfile }}-${{ hashFiles('**/package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', 'pnpmfile.cjs', '.github/actions/setup-node-env/sticky-importers.sh', 'scripts/postinstall-bundled-plugins.mjs', 'scripts/preinstall-package-manager-warning.mjs', 'scripts/prepare-git-hooks.mjs') }} + # One stable disk per node-version. The v2 per-PR/per-manifest-hash + # keys minted a new backing disk for every PR and dependency change + # and saturated Blacksmith's installation-wide sticky-disk budget, + # 429-failing every mount. Install inputs live in the runtime + # fingerprint marker below instead of the key, so dependency changes + # refresh this disk in place rather than allocating a new one. + key: ${{ github.repository }}-node-deps-bind-v3-${{ inputs.node-version }} path: /var/tmp/openclaw-node-deps - # v1.4.0 skips commit after failed/cancelled steps or ambiguous failure - # detection, so an incomplete first hydration cannot seed this key. - commit: if-missing + # Single semantic writer: only the designated trusted-push job may + # commit, so pull_request clones stay read-only. Like every sticky + # disk here, this gate binds cooperating code, not hostile code: the + # enforced trust boundary is the fork/dispatch runner gate in ci.yml, + # 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. + commit: ${{ inputs.save-sticky-disk == 'true' && github.event_name != 'pull_request' && 'true' || 'false' }} - name: Mount protected Vitest transform seed if: inputs.vitest-fs-cache == 'true' && (inputs.sticky-disk == 'true' || inputs.runtime-cache-sticky-disk == 'true') && github.event_name == 'pull_request' && runner.os != 'Windows' @@ -254,6 +273,12 @@ runs: - name: Bind sticky node_modules into workspace if: inputs.sticky-disk == 'true' shell: bash + env: + # Evaluated before this step's bind mount populates node_modules, so + # the '**/package.json' glob only sees checkout manifests. Recomputing + # this hash any later would sweep snapshot-internal package.json files + # into the fingerprint and permanently miss the warm path. + DEPS_FINGERPRINT: node-${{ inputs.node-version }}-frozen-${{ inputs.frozen-lockfile }}-${{ hashFiles('**/package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', 'pnpmfile.cjs', '.github/actions/setup-node-env/sticky-importers.sh', 'scripts/postinstall-bundled-plugins.mjs', 'scripts/preinstall-package-manager-warning.mjs', 'scripts/prepare-git-hooks.mjs') }} run: | set -euo pipefail sticky_root=/var/tmp/openclaw-node-deps @@ -271,6 +296,7 @@ runs: findmnt --target "$workspace_modules" # zizmor: ignore[github-env] static trusted path defined in this composite action. echo "PNPM_CONFIG_STORE_DIR=$sticky_store" >> "$GITHUB_ENV" + echo "OPENCLAW_STICKY_DEPS_FINGERPRINT=$DEPS_FINGERPRINT" >> "$GITHUB_ENV" # pnpm exec may reconcile a restored workspace before nested builds. # Sticky jobs already have every build tool, so invoke their Node entrypoints directly. echo "OPENCLAW_BUILD_ALL_NO_PNPM=1" >> "$GITHUB_ENV" @@ -309,6 +335,7 @@ runs: FROZEN_LOCKFILE: ${{ inputs.frozen-lockfile }} STICKY_DISK: ${{ inputs.sticky-disk }} STICKY_ROOT: /var/tmp/openclaw-node-deps + STICKY_WRITER: ${{ inputs.save-sticky-disk == 'true' && github.event_name != 'pull_request' && 'true' || 'false' }} run: | set -euo pipefail export PATH="$NODE_BIN:$PATH" @@ -353,19 +380,36 @@ runs: ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules" export NODE_PATH="$PNPM_CONFIG_MODULES_DIR${NODE_PATH:+:$NODE_PATH}" fi - sticky_ready_marker="$STICKY_ROOT/.install-complete-v2" - if [ "$STICKY_DISK" = "true" ] && [ -f "$sticky_ready_marker" ]; then + sticky_marker="$STICKY_ROOT/.openclaw-deps-fingerprint" + sticky_fingerprint="" + if [ "$STICKY_DISK" = "true" ] && [ -f "$sticky_marker" ]; then + sticky_fingerprint="$(<"$sticky_marker")" + fi + if [ "$STICKY_DISK" = "true" ] && [ -n "$sticky_fingerprint" ] && + [ "$sticky_fingerprint" = "${OPENCLAW_STICKY_DEPS_FINGERPRINT:?}" ]; then bash "$GITHUB_ACTION_PATH/sticky-importers.sh" restore "$STICKY_ROOT" "$GITHUB_WORKSPACE" - echo "Sticky dependency snapshot is ready; skipping pnpm install" + echo "Sticky dependency snapshot matches the install fingerprint; skipping pnpm install" else + if [ "$STICKY_DISK" = "true" ] && [ -n "$sticky_fingerprint" ]; then + echo "Sticky dependency snapshot is stale (disk: $sticky_fingerprint, want: $OPENCLAW_STICKY_DEPS_FINGERPRINT); reinstalling" + fi + # A stale marker must not survive a mid-install failure: the commit + # heuristics skip failed steps, but a wrong marker plus a partial + # tree would poison every consumer if one ever slipped through. + if [ "$STICKY_DISK" = "true" ] && [ "$STICKY_WRITER" = "true" ]; then + rm -f "$sticky_marker" + fi pnpm "${install_args[@]}" || pnpm "${install_args[@]}" if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then rm -rf node_modules ln -sfn "$PNPM_CONFIG_MODULES_DIR" node_modules ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules" fi - if [ "$STICKY_DISK" = "true" ]; then - bash "$GITHUB_ACTION_PATH/sticky-importers.sh" capture "$STICKY_ROOT" "$GITHUB_WORKSPACE" + # Only the designated trusted writer captures importer archives and + # publishes the fingerprint; read-only clones are discarded at job + # end, so capturing there would only burn shard wall clock. + if [ "$STICKY_DISK" = "true" ] && [ "$STICKY_WRITER" = "true" ]; then + bash "$GITHUB_ACTION_PATH/sticky-importers.sh" capture "$STICKY_ROOT" "$GITHUB_WORKSPACE" "$OPENCLAW_STICKY_DEPS_FINGERPRINT" fi fi diff --git a/.github/actions/setup-node-env/sticky-importers.sh b/.github/actions/setup-node-env/sticky-importers.sh index 0481c53bf456..053ae9268f95 100644 --- a/.github/actions/setup-node-env/sticky-importers.sh +++ b/.github/actions/setup-node-env/sticky-importers.sh @@ -5,10 +5,11 @@ mode="${1:?mode is required}" sticky_root="${2:?sticky root is required}" workspace="${3:?workspace is required}" archive="$sticky_root/importer-node-modules.tar" -marker="$sticky_root/.install-complete-v2" +marker="$sticky_root/.openclaw-deps-fingerprint" case "$mode" in capture) + fingerprint="${4:?fingerprint is required}" mkdir -p "$sticky_root" list_file="$(mktemp)" temp_archive="$archive.tmp.$$" @@ -22,7 +23,9 @@ case "$mode" in tar --create --file "$temp_archive" --null --files-from "$list_file" ) mv "$temp_archive" "$archive" - touch "$marker" + # The marker lands last: a fingerprint is only ever visible next to the + # importer archive it describes, so consumers cannot restore a torn pair. + printf '%s\n' "$fingerprint" > "$marker" ;; restore) if [[ ! -f "$archive" ]]; then diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24094cbb0b3e..e14879991e8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1016,6 +1016,15 @@ jobs: install-bun: "false" node-compile-cache: "true" node-compile-cache-scope: "build" + # Blacksmith same-repo runs clone dependencies from a sticky disk. + # Fork PRs must keep actions/cache: sticky snapshots are writable, + # 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' }} + # Designated dependency-snapshot writer: exactly one trusted-push job + # per node-version key refreshes the protected disk; every other + # sticky consumer mounts read-only. + save-sticky-disk: "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' }} @@ -1227,6 +1236,11 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" + # Blacksmith same-repo runs clone dependencies from a sticky disk. + # Fork PRs must keep actions/cache: sticky snapshots are writable, + # 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' }} - name: Check native app i18n inventory run: pnpm native:i18n:check @@ -1254,6 +1268,11 @@ jobs: with: node-version: "24.x" install-bun: "false" + # Blacksmith same-repo runs clone dependencies from a sticky disk. + # Fork PRs must keep actions/cache: sticky snapshots are writable, + # 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' }} - name: Install Playwright Chromium run: | @@ -1298,6 +1317,11 @@ jobs: with: node-version: "24.x" install-bun: "false" + # Blacksmith same-repo runs clone dependencies from a sticky disk. + # Fork PRs must keep actions/cache: sticky snapshots are writable, + # 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' }} - name: Check Control UI locale parity run: pnpm ui:i18n:check @@ -1371,6 +1395,11 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: ${{ matrix.task == 'bun-launcher' && 'true' || 'false' }} + # Blacksmith same-repo runs clone dependencies from a sticky disk. + # Fork PRs must keep actions/cache: sticky snapshots are writable, + # 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' }} - name: Run ${{ matrix.task }} (${{ matrix.runtime }}) env: @@ -1500,6 +1529,11 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" + # Blacksmith same-repo runs clone dependencies from a sticky disk. + # Fork PRs must keep actions/cache: sticky snapshots are writable, + # 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' }} - name: Set up Blacksmith Docker layer cache if: ${{ matrix.docker_cache == true && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') }} @@ -1671,6 +1705,11 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" + # Blacksmith same-repo runs clone dependencies from a sticky disk. + # Fork PRs must keep actions/cache: sticky snapshots are writable, + # 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' }} - name: Run plugin contract shard env: @@ -1709,6 +1748,11 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" + # Blacksmith same-repo runs clone dependencies from a sticky disk. + # Fork PRs must keep actions/cache: sticky snapshots are writable, + # 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' }} - name: Run channel contract shard env: @@ -1925,6 +1969,11 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" + # Blacksmith same-repo runs clone dependencies from a sticky disk. + # Fork PRs must keep actions/cache: sticky snapshots are writable, + # 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' }} # check-lint's shard runner rebuilds the same plugin-sdk boundary # artifacts the boundary lane snapshots (~72s cold); restore them from @@ -2143,6 +2192,11 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" + # Blacksmith same-repo runs clone dependencies from a sticky disk. + # Fork PRs must keep actions/cache: sticky snapshots are writable, + # 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' }} # Same-repo runs carry boundary artifacts on a Blacksmith sticky disk: # the GitHub cache below is evicted so quickly under the repo quota that @@ -2382,6 +2436,11 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" + # Blacksmith same-repo runs clone dependencies from a sticky disk. + # Fork PRs must keep actions/cache: sticky snapshots are writable, + # 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' }} - name: Check formatting if: needs.preflight.outputs.run_format_check == 'true' diff --git a/.github/workflows/vitest-cache-warm.yml b/.github/workflows/vitest-cache-warm.yml index 3e5389c0752b..ab2c3d0b33aa 100644 --- a/.github/workflows/vitest-cache-warm.yml +++ b/.github/workflows/vitest-cache-warm.yml @@ -30,6 +30,11 @@ jobs: node-compile-cache-scope: "test" runtime-cache-sticky-disk: "true" save-node-compile-cache: "true" + # Daily deadline writer for the protected dependency snapshot: + # canonical main pushes cancel each other under merge traffic, so + # build-artifacts commits can starve; this scheduled run cannot be + # superseded and keeps the snapshot within a day of main. + save-sticky-disk: "true" save-vitest-fs-cache: "true" sticky-disk: "true" use-actions-cache: "false" diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index ac4fc69e8cd9..90ab87398fdb 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -1886,11 +1886,73 @@ describe("ci workflow guards", () => { const runsOn = (job as { "runs-on"?: unknown })["runs-on"]; return typeof runsOn === "string" && runsOn.includes("blacksmith-"); }); - const setupNodeStep = workflow.jobs["checks-node-core-test-nondist-shard"].steps.find( + const stickyConsumers = Object.entries(workflow.jobs).flatMap(([jobName, job]) => { + const steps = (job as { steps?: WorkflowStep[] }).steps ?? []; + return steps.flatMap((step) => { + const stepWith = step.with; + if (!stepWith || stepWith["sticky-disk"] === undefined) { + return []; + } + return [{ jobName, stepWith }]; + }); + }); + // 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([ + "build-artifacts", + "check-additional-shard", + "check-docs", + "check-shard", + "checks-fast-channel-contracts-shard", + "checks-fast-core", + "checks-fast-plugin-contracts-shard", + "checks-node-core-test-nondist-shard", + "checks-ui", + "control-ui-i18n", + "native-i18n", + "qa-smoke-ci-profile", + ]); + for (const { jobName, stepWith } of stickyConsumers) { + const stickyCondition = stepWith["sticky-disk"]; + const cacheCondition = stepWith["use-actions-cache"]; + expect(stickyCondition, jobName).toContain("github.event_name != 'workflow_dispatch'"); + expect(stickyCondition, jobName).toContain( + "github.event.pull_request.head.repo.full_name == 'openclaw/openclaw'", + ); + expect(cacheCondition, jobName).toContain("github.event_name != 'workflow_dispatch'"); + expect(cacheCondition, jobName).toContain( + "github.event.pull_request.head.repo.full_name == 'openclaw/openclaw'", + ); + expect(cacheCondition, jobName).toContain("&& 'false' || 'true'"); + } + // Exactly one CI job may publish the shared snapshot; concurrent + // committers would race the disk and consumers could clone a torn tree. + const ciWriters = stickyConsumers.filter( + (entry) => entry.stepWith["save-sticky-disk"] === "true", + ); + expect(ciWriters.map((entry) => entry.jobName)).toEqual(["build-artifacts"]); + // The disk key is partitioned by node-version and both writers rely on + // the action default; a consumer pinning any other version would split + // onto a writerless key and silently regress to permanently cold installs. + for (const { jobName, stepWith } of stickyConsumers) { + const nodeVersion = stepWith["node-version"]; + expect( + nodeVersion === undefined || + nodeVersion === "24.x" || + nodeVersion === "${{ matrix.node_version || '24.x' }}", + `${jobName} must resolve to the writer's 24.x snapshot key (got ${String(nodeVersion)})`, + ).toBe(true); + } + 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", ); - const stickyCondition = setupNodeStep.with["sticky-disk"]; - const cacheCondition = setupNodeStep.with["use-actions-cache"]; + // The scheduled warm run is the deadline writer: canonical main pushes + // cancel each other under merge traffic, so build-artifacts alone could + // starve the snapshot for days. + expect(warmSetupStep.with["save-sticky-disk"]).toBe("true"); + expect(warmWorkflow.on).not.toHaveProperty("pull_request"); + expect(warmWorkflow.on).not.toHaveProperty("workflow_dispatch"); 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", @@ -1920,16 +1982,10 @@ describe("ci workflow guards", () => { "github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw'", ); } - expect(stickyCondition).toContain("github.event_name != 'workflow_dispatch'"); - expect(stickyCondition).toContain( - "github.event.pull_request.head.repo.full_name == 'openclaw/openclaw'", - ); - expect(cacheCondition).toContain("github.event_name != 'workflow_dispatch'"); - expect(cacheCondition).toContain( - "github.event.pull_request.head.repo.full_name == 'openclaw/openclaw'", - ); - expect(cacheCondition).toContain("&& 'false' || 'true'"); expect(action.inputs["sticky-disk"].default).toBe("false"); + // Writers omit node-version, so the default is the writers' key segment. + expect(action.inputs["node-version"].default).toBe("24.x"); + expect(action.inputs["save-sticky-disk"].default).toBe("false"); expect(validateLayoutStep.if).toBe("inputs.sticky-disk == 'true'"); expect(validateLayoutStep.run).toContain("for config_name in modules-dir virtual-store-dir"); expect(validateLayoutStep.run).toContain('config_value="$(pnpm config get "$config_name")"'); @@ -1947,17 +2003,17 @@ describe("ci workflow guards", () => { uses: "useblacksmith/stickydisk@5b350170ae4ef55b536b548ef5f5896e76a6b54f", with: { path: "/var/tmp/openclaw-node-deps", - commit: "if-missing", }, }); - expect(mountStep.with.key).toContain("node-deps-bind-v2-"); - expect(mountStep.with.key).toContain("format('pr-{0}', github.event.pull_request.number)"); - expect(mountStep.with.key).toContain("inputs.frozen-lockfile"); - expect(mountStep.with.key).toContain("hashFiles('**/package.json', 'pnpm-lock.yaml'"); - expect(mountStep.with.key).toContain("'.npmrc'"); - expect(mountStep.with.key).toContain("'.pnpmfile.cjs'"); - expect(mountStep.with.key).toContain(".github/actions/setup-node-env/sticky-importers.sh"); - expect(mountStep.with.key).toContain("scripts/postinstall-bundled-plugins.mjs"); + // O(1) disks: Blacksmith caps sticky disks per installation, and the old + // per-PR/per-manifest-hash keys saturated that cap (mounts 429ed fleet + // wide). Install inputs belong in the runtime fingerprint, not the key. + expect(mountStep.with.key).toBe( + "${{ github.repository }}-node-deps-bind-v3-${{ inputs.node-version }}", + ); + expect(mountStep.with.commit).toBe( + "${{ inputs.save-sticky-disk == 'true' && github.event_name != 'pull_request' && 'true' || 'false' }}", + ); expect(cleanupStep).toMatchObject({ if: "inputs.sticky-disk == 'true'", uses: "./.github/actions/register-bind-mount-cleanup", @@ -1972,20 +2028,43 @@ describe("ci workflow guards", () => { expect(bindStep.run).toContain('sudo mount --bind "$sticky_modules" "$workspace_modules"'); expect(bindStep.run).toContain('echo "PNPM_CONFIG_STORE_DIR=$sticky_store"'); expect(bindStep.run).toContain('echo "OPENCLAW_BUILD_ALL_NO_PNPM=1"'); + expect(bindStep.run).toContain('echo "OPENCLAW_STICKY_DEPS_FINGERPRINT=$DEPS_FINGERPRINT"'); expect(bindStep.run).not.toContain("PNPM_CONFIG_MODULES_DIR"); expect(bindStep.run).not.toContain("PNPM_CONFIG_VIRTUAL_STORE_DIR"); + // The fingerprint must be evaluated on this step (before its bind mount + // lands): any later hashFiles('**/package.json') sweep would include + // snapshot-internal manifests and permanently miss the warm path. + const fingerprintEnv = bindStep.env.DEPS_FINGERPRINT; + expect(fingerprintEnv).toContain("node-${{ inputs.node-version }}"); + expect(fingerprintEnv).toContain("frozen-${{ inputs.frozen-lockfile }}"); + expect(fingerprintEnv).toContain("hashFiles('**/package.json', 'pnpm-lock.yaml'"); + expect(fingerprintEnv).toContain("'pnpm-workspace.yaml'"); + expect(fingerprintEnv).toContain("'.npmrc'"); + expect(fingerprintEnv).toContain("'.pnpmfile.cjs'"); + expect(fingerprintEnv).toContain(".github/actions/setup-node-env/sticky-importers.sh"); + expect(fingerprintEnv).toContain("scripts/postinstall-bundled-plugins.mjs"); expect(installStep.env).toMatchObject({ STICKY_DISK: "${{ inputs.sticky-disk }}", STICKY_ROOT: "/var/tmp/openclaw-node-deps", + STICKY_WRITER: + "${{ inputs.save-sticky-disk == 'true' && github.event_name != 'pull_request' && 'true' || 'false' }}", }); - expect(installStep.run).toContain('sticky_ready_marker="$STICKY_ROOT/.install-complete-v2"'); + expect(installStep.run).toContain('sticky_marker="$STICKY_ROOT/.openclaw-deps-fingerprint"'); + expect(installStep.run).toContain( + '[ "$sticky_fingerprint" = "${OPENCLAW_STICKY_DEPS_FINGERPRINT:?}" ]', + ); expect(installStep.run).toContain( 'bash "$GITHUB_ACTION_PATH/sticky-importers.sh" restore "$STICKY_ROOT" "$GITHUB_WORKSPACE"', ); - expect(installStep.run).toContain("Sticky dependency snapshot is ready; skipping pnpm install"); + expect(installStep.run).toContain( + "Sticky dependency snapshot matches the install fingerprint; skipping pnpm install", + ); + // Read-only consumers never capture; only the designated writer refreshes + // the archive and publishes the fingerprint after a successful install. + expect(installStep.run).toContain('[ "$STICKY_WRITER" = "true" ]'); expect(installStep.run.indexOf('pnpm "${install_args[@]}"')).toBeLessThan( installStep.run.indexOf( - 'bash "$GITHUB_ACTION_PATH/sticky-importers.sh" capture "$STICKY_ROOT" "$GITHUB_WORKSPACE"', + 'bash "$GITHUB_ACTION_PATH/sticky-importers.sh" capture "$STICKY_ROOT" "$GITHUB_WORKSPACE" "$OPENCLAW_STICKY_DEPS_FINGERPRINT"', ), ); const cleanupAction = parse( @@ -2059,7 +2138,7 @@ describe("ci workflow guards", () => { writeFileSync(path.join(rootModules, "root-sentinel"), "before", "utf8"); symlinkSync("../../../node_modules/shared", path.join(importerModules, "shared")); - execFileSync("bash", [helper, "capture", stickyRoot, workspace]); + execFileSync("bash", [helper, "capture", stickyRoot, workspace, "fingerprint-a"]); rmSync(importerModules, { recursive: true }); writeFileSync(path.join(rootModules, "root-sentinel"), "after", "utf8"); execFileSync("bash", [helper, "restore", stickyRoot, workspace]); @@ -2068,6 +2147,10 @@ describe("ci workflow guards", () => { "../../../node_modules/shared", ); expect(readFileSync(path.join(rootModules, "root-sentinel"), "utf8")).toBe("after"); + expect(readFileSync(path.join(stickyRoot, ".openclaw-deps-fingerprint"), "utf8")).toBe( + "fingerprint-a\n", + ); + expect(() => execFileSync("bash", [helper, "capture", stickyRoot, workspace])).toThrow(); } finally { rmSync(root, { recursive: true, force: true }); }