name: Setup Node environment description: > Install Node 24 by default, pnpm, optionally Bun, and optionally run pnpm install. Requires actions/checkout to run first. inputs: node-version: description: Node.js version to install. required: false default: "24.x" install-bun: description: Whether to install Bun alongside Node. required: false default: "true" install-trufflehog: description: Whether to install the pinned TruffleHog binary for a reusable test environment. required: false default: "false" install-deps: description: Whether to run pnpm install after environment setup. required: false default: "true" frozen-lockfile: description: Whether to use --frozen-lockfile for install. required: false default: "true" use-actions-cache: description: Whether to restore the pnpm store with actions/cache. required: false default: "true" save-actions-cache: description: Whether to save the pnpm store with actions/cache after install when no exact cache restored. required: false default: "false" dependency-cache: description: Whether to restore workspace node_modules and its local pnpm store from the exact semantic dependency cache. required: false default: "false" save-dependency-cache: description: Whether to save workspace node_modules and its local pnpm store after a successful install on an exact cache miss. required: false default: "false" vitest-fs-cache: description: Whether to persist Vitest's experimental filesystem module cache. required: false default: "false" restore-test-caches: description: Whether to restore the Vitest transform and test-scope Node compile caches together. required: false default: "false" node-compile-cache: description: Whether to persist Node's on-disk V8 compile cache. required: false default: "false" node-compile-cache-scope: description: Cache namespace for isolating workloads with different writer ownership. required: false default: "test" save-node-compile-cache: description: Whether this job may save the Node compile cache. required: false default: "false" save-vitest-fs-cache: description: Whether this job may save the shared Vitest filesystem module cache. required: false default: "false" build-all-cache-scope: description: > Namespace for restoring and saving build-all's content-addressed step cache. Leave empty to disable; use only for declaration builds with public inputs. required: false default: "" runs: using: composite steps: - name: Normalize container toolcache shell: bash run: | set -euo pipefail if [[ -d /__t && ! -e /opt/hostedtoolcache ]]; then mkdir -p /opt ln -s /__t /opt/hostedtoolcache fi # Blacksmith's image tracks an older runner-images snapshot whose toolcache # Node patches sit just under this repo's engines floor (measured 2026-08-16: # 20.20.0/22.22.0/24.13.0 against >=22.22.3 and >=24.15.0), so every job # falls through to a nodejs.org download. That is ~2.6s normally but tail- # spikes past 110s when the mirror throttles our ~46 simultaneous fetches, # and it lands on every job at once. Keep the payload in the Actions cache, # which Blacksmith serves from its colocated backend. A stale or corrupt # entry is self-healing: ensure-node probes each candidate's version and # falls back to the download when none satisfies the floor. # Restore by prefix, never by exact key: cache entries are immutable and an # exact hit suppresses the post-job save, so a floating `24.x` key would pin # the first Node it ever saw. Once the floor advances past it every job would # restore the rejected payload and re-download forever. The save below is # keyed on the version actually installed, so a newer resolve publishes a new # entry and later prefix restores pick it up. # GitHub-hosted images carry a Node that already clears the floor, so they # resolve from /opt/hostedtoolcache and would only ever miss here, then warn # on a save whose path was never created. Scope both steps to self-hosted. - name: Restore Node toolchain cache if: runner.os != 'Windows' && runner.environment != 'github-hosted' id: node-toolchain-restore continue-on-error: true uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ${{ runner.temp }}/openclaw-node-toolchain/node key: openclaw-node-toolchain-v1-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}- restore-keys: | openclaw-node-toolchain-v1-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}- - name: Setup Node.js id: setup-node shell: bash env: REQUESTED_NODE_VERSION: ${{ inputs.node-version }} OPENCLAW_NODE_TOOLCHAIN_ROOT: ${{ runner.os != 'Windows' && format('{0}/openclaw-node-toolchain/node', runner.temp) || '' }} run: | set -euo pipefail source "$GITHUB_ACTION_PATH/../setup-pnpm-store-cache/ensure-node.sh" openclaw_ensure_node "$REQUESTED_NODE_VERSION" echo "resolved-version=$(node -p 'process.versions.node')" >> "$GITHUB_OUTPUT" # Only a download populates the cached root. An image whose toolcache # already clears the floor leaves it absent, and saving a missing path # warns without producing an entry. if [ -n "${OPENCLAW_NODE_TOOLCHAIN_ROOT:-}" ] && [ -d "${OPENCLAW_NODE_TOOLCHAIN_ROOT}" ]; then echo "toolchain-populated=true" >> "$GITHUB_OUTPUT" fi # Skipped when the restore already matched this exact resolved version, so a # warm run uploads nothing. On a version change the 46-way fanout races here; # the losers log a benign "cache already exists" and continue. - name: Save Node toolchain cache if: ${{ runner.os != 'Windows' && runner.environment != 'github-hosted' && steps.setup-node.outputs.toolchain-populated == 'true' && steps.node-toolchain-restore.outputs.cache-matched-key != format('openclaw-node-toolchain-v1-{0}-{1}-{2}-{3}', runner.os, runner.arch, inputs.node-version, steps.setup-node.outputs.resolved-version) }} continue-on-error: true uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ${{ runner.temp }}/openclaw-node-toolchain/node key: openclaw-node-toolchain-v1-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}-${{ steps.setup-node.outputs.resolved-version }} - name: Configure dependency cache store if: inputs.dependency-cache == 'true' shell: bash run: | set -euo pipefail # Keep both sides of pnpm's hard links below the workspace so one tar # archive preserves them without relying on runner-home path depth. echo "PNPM_CONFIG_STORE_DIR=$GITHUB_WORKSPACE/.cache/openclaw-pnpm-store" >> "$GITHUB_ENV" - name: Resolve dependency cache key id: dependency-cache-key if: inputs.dependency-cache == 'true' shell: bash env: FROZEN_LOCKFILE: ${{ inputs.frozen-lockfile }} run: | set -euo pipefail deps_input_fingerprint="$(node "$GITHUB_ACTION_PATH/dependency-fingerprint.mjs" \ --workspace "$GITHUB_WORKSPACE" --frozen-lockfile "$FROZEN_LOCKFILE")" cache_key="${GITHUB_REPOSITORY:?}-node-deps-v2-os-${RUNNER_OS:?}-arch-${RUNNER_ARCH:?}-node-$(node --version)-${deps_input_fingerprint:?}" echo "key=$cache_key" >> "$GITHUB_OUTPUT" - name: Prepare dependency cache restore if: inputs.dependency-cache == 'true' shell: bash run: | rm -rf "$GITHUB_WORKSPACE/node_modules" "$GITHUB_WORKSPACE/.cache/openclaw-pnpm-store" find \ "$GITHUB_WORKSPACE/ui" \ "$GITHUB_WORKSPACE/packages" \ "$GITHUB_WORKSPACE/extensions" \ "$GITHUB_WORKSPACE/examples" \ -mindepth 1 -maxdepth 2 \( -type d -o -type l \) -name node_modules \ -exec rm -rf -- {} + - name: Restore exact dependency cache id: dependency-cache if: inputs.dependency-cache == 'true' continue-on-error: true uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: | node_modules ui/node_modules packages/*/node_modules examples/*/node_modules .cache/openclaw-pnpm-store key: ${{ steps.dependency-cache-key.outputs.key }} - name: Prepare dependency cache miss fallback if: inputs.dependency-cache == 'true' && steps.dependency-cache.outputs.cache-hit != 'true' shell: bash run: | # actions/cache treats service, download, and extraction failures as # misses. Clear any partial extraction before restoring the pnpm store. rm -rf "$GITHUB_WORKSPACE/node_modules" "$GITHUB_WORKSPACE/.cache/openclaw-pnpm-store" find \ "$GITHUB_WORKSPACE/ui" \ "$GITHUB_WORKSPACE/packages" \ "$GITHUB_WORKSPACE/extensions" \ "$GITHUB_WORKSPACE/examples" \ -mindepth 1 -maxdepth 2 \( -type d -o -type l \) -name node_modules \ -exec rm -rf -- {} + - name: Setup pnpm id: setup-pnpm uses: ./.github/actions/setup-pnpm-store-cache with: node-version: ${{ inputs.node-version }} # On an exact dependency-cache hit, the same archive already restored # the complete store. Every miss can seed it from the coarser cache, # including legacy Blacksmith callers that disabled that old fallback. use-actions-cache: ${{ ((inputs.dependency-cache == 'true' && steps.dependency-cache.outputs.cache-hit != 'true') || (inputs.dependency-cache != 'true' && inputs.use-actions-cache == 'true')) && 'true' || 'false' }} - name: Setup TruffleHog if: inputs.install-trufflehog == 'true' shell: bash run: bash scripts/install-trufflehog.sh - name: Restore and save Vitest transform cache 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 # 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/**', 'src/state/*.sql', '!**/node_modules/**') }}-${{ github.run_id }}-${{ github.run_attempt }} restore-keys: | ${{ 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/**', 'src/state/*.sql', '!**/node_modules/**') }}- - name: Restore Vitest transform cache if: (inputs.vitest-fs-cache == 'true' || inputs.restore-test-caches == '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-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/**', 'src/state/*.sql', '!**/node_modules/**') }}-${{ github.run_id }}-${{ github.run_attempt }} restore-keys: | ${{ 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/**', 'src/state/*.sql', '!**/node_modules/**') }}- - name: Configure Vitest transform cache if: (inputs.vitest-fs-cache == 'true' || inputs.restore-test-caches == 'true') && runner.os != 'Windows' env: CACHE_GENERATION: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**', 'src/state/*.sql', '!**/node_modules/**') }} CACHE_WRITER: ${{ inputs.save-vitest-fs-cache == 'true' && '1' || '0' }} shell: bash run: | set -euo pipefail cache_root=/var/tmp/openclaw-vitest-fs-cache generation_file="$cache_root/.openclaw-transform-generation" mkdir -p "$cache_root" cache_generation="" if [[ -f "$generation_file" ]]; then cache_generation="$(<"$generation_file")" fi cache_entry="$(find "$cache_root" -mindepth 1 -maxdepth 1 -print -quit)" # 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 -- {} + fi printf '%s\n' "$CACHE_GENERATION" > "$generation_file" # The shard runner treats this as a persistent root and assigns one # isolated subdirectory per concurrent Vitest worker. echo "OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=$cache_root" >> "$GITHUB_ENV" echo "OPENCLAW_VITEST_FS_MODULE_CACHE_WRITER=$CACHE_WRITER" >> "$GITHUB_ENV" - name: Select Node compile cache epoch id: node-compile-cache-epoch if: (inputs.node-compile-cache == 'true' || inputs.restore-test-caches == '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.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-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-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.restore-test-caches == '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-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-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' || inputs.restore-test-caches == 'true') && runner.os != 'Windows' env: CACHE_WRITER: ${{ inputs.save-node-compile-cache == 'true' && '1' || '0' }} shell: bash run: | set -euo pipefail cache_root=/var/tmp/openclaw-node-compile-cache mkdir -p "$cache_root" echo "NODE_COMPILE_CACHE=$cache_root" >> "$GITHUB_ENV" echo "NODE_COMPILE_CACHE_PORTABLE=1" >> "$GITHUB_ENV" echo "OPENCLAW_NODE_COMPILE_CACHE_WRITER=$CACHE_WRITER" >> "$GITHUB_ENV" - name: Setup Bun if: inputs.install-bun == 'true' shell: bash run: | set -euo pipefail npm install -g bun@1.3.14 - name: Runtime versions shell: bash run: | node -v npm -v pnpm -v if command -v bun &>/dev/null; then bun -v; fi - name: Capture node path shell: bash run: | node_bin="$(dirname "$(node -p 'process.execPath')")" if command -v cygpath >/dev/null 2>&1; then node_bin="$(cygpath -u "$node_bin")" fi # zizmor: ignore[github-env] node_bin comes from trusted actions/setup-node output in this composite action. echo "NODE_BIN=$node_bin" >> "$GITHUB_ENV" echo "$node_bin" >> "$GITHUB_PATH" - name: Install dependencies if: inputs.install-deps == 'true' shell: bash env: CI: "true" DEPENDENCY_CACHE: ${{ inputs.dependency-cache }} DEPENDENCY_CACHE_HIT: ${{ steps.dependency-cache.outputs.cache-hit }} FROZEN_LOCKFILE: ${{ inputs.frozen-lockfile }} run: | set -euo pipefail export PATH="$NODE_BIN:$PATH" which node node -v pnpm -v case "$FROZEN_LOCKFILE" in true) LOCKFILE_FLAG="--frozen-lockfile" ;; false) LOCKFILE_FLAG="" ;; *) echo "::error::Invalid frozen-lockfile input: '$FROZEN_LOCKFILE' (expected true or false)" exit 2 ;; esac install_args=( install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true ) if [ "$DEPENDENCY_CACHE" = "true" ]; then # Both trees live below the workspace. Prefer real hard links so the # single cache archive can preserve store/package identity; pnpm # safely falls back to copies for files it cannot hard-link. install_args+=(--package-import-method=hardlink) fi if [ -n "$LOCKFILE_FLAG" ]; then install_args+=("$LOCKFILE_FLAG") fi append_pnpm_option_arg() { local env_name="$1" local option_name="$2" local value="${!env_name-}" if [ -n "$value" ]; then install_args+=("--${option_name}=${value}") fi } append_pnpm_option_arg PNPM_CONFIG_CHILD_CONCURRENCY child-concurrency append_pnpm_option_arg PNPM_CONFIG_MODULES_DIR modules-dir append_pnpm_option_arg PNPM_CONFIG_NETWORK_CONCURRENCY network-concurrency append_pnpm_option_arg PNPM_CONFIG_STORE_DIR store-dir append_pnpm_option_arg PNPM_CONFIG_VIRTUAL_STORE_DIR virtual-store-dir run_pnpm_install() { local fetch_mode="$1" pnpm "${install_args[@]}" "$fetch_mode" } clear_dependency_modules() { rm -rf "$GITHUB_WORKSPACE/node_modules" find \ "$GITHUB_WORKSPACE/ui" \ "$GITHUB_WORKSPACE/packages" \ "$GITHUB_WORKSPACE/extensions" \ "$GITHUB_WORKSPACE/examples" \ -mindepth 1 -maxdepth 2 \( -type d -o -type l \) -name node_modules \ -exec rm -rf -- {} + } if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then mkdir -p "$PNPM_CONFIG_MODULES_DIR" ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules" export NODE_PATH="$PNPM_CONFIG_MODULES_DIR${NODE_PATH:+:$NODE_PATH}" fi install_status=0 if [ "$DEPENDENCY_CACHE_HIT" = "true" ]; then run_pnpm_install --offline || install_status="$?" else run_pnpm_install --prefer-offline || install_status="$?" fi if [ "$install_status" -ne 0 ] && [ "$DEPENDENCY_CACHE_HIT" = "true" ]; then echo "::warning::Cached dependency tree failed pnpm reconciliation; relinking it from the restored store" clear_dependency_modules install_status=0 run_pnpm_install --offline || install_status="$?" fi if [ "$install_status" -ne 0 ] && [ "$DEPENDENCY_CACHE_HIT" = "true" ]; then echo "::warning::Restored dependency store failed pnpm reconciliation; retrying from an empty store" clear_dependency_modules rm -rf "${PNPM_CONFIG_STORE_DIR:?}" install_status=0 run_pnpm_install --prefer-offline || install_status="$?" fi if [ "$install_status" -ne 0 ]; then echo "::error::pnpm install failed" exit "$install_status" fi 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 [ "$DEPENDENCY_CACHE" = "true" ]; then # The exact archive includes importer links, and frozen offline # reconciliation validates them without reaching the registry. Later # build wrappers can use installed Node entrypoints directly. echo "OPENCLAW_BUILD_ALL_NO_PNPM=1" >> "$GITHUB_ENV" # Postinstall intentionally prunes plugin-local node_modules. Pnpm's # redundant pre-run check treats that as stale and can launch unsafe # concurrent implicit installs after CI fans out. # zizmor: ignore[github-env] static pnpm policy owned by this action. echo "pnpm_config_verify_deps_before_run=false" >> "$GITHUB_ENV" fi - name: Save exact dependency cache if: inputs.install-deps == 'true' && inputs.dependency-cache == 'true' && inputs.save-dependency-cache == 'true' && steps.dependency-cache.outputs.cache-hit != 'true' && steps.dependency-cache.outcome != 'failure' continue-on-error: true uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: | node_modules ui/node_modules packages/*/node_modules examples/*/node_modules .cache/openclaw-pnpm-store key: ${{ steps.dependency-cache-key.outputs.key }} - name: Restore and save build-all cache if: inputs.build-all-cache-scope != '' uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: .artifacts/build-all-cache # Exact keys deduplicate concurrent jobs. Coarse restore supplies the # newest declaration groups; build-all rehashes every group's inputs. key: ${{ github.repository }}-build-all-v1-${{ inputs.build-all-cache-scope }}-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'tsconfig*.json', 'tsdown*.config.ts', 'scripts/build-all.mts', 'scripts/tsdown-build.mts', 'scripts/lib/tsdown-*.mts', 'scripts/lib/plugin-sdk-*', 'scripts/lib/bundled-plugin-*', 'scripts/lib/optional-bundled-clusters.mjs', 'src/**', 'packages/**', 'extensions/**') }} restore-keys: | ${{ github.repository }}-build-all-v1-${{ inputs.build-all-cache-scope }}-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}- # Prune before saving: prefix-key restores accrete every prior lockfile # generation into the archive (measured 2.05 GiB, ~36s restore per job). # Pruning collapses it to the current lockfile's closure; a dropped entry # costs one registry refetch in a later job at worst. - name: Prune pnpm store before save if: ${{ inputs.install-deps == 'true' && inputs.use-actions-cache == 'true' && (inputs.dependency-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true') && inputs.save-actions-cache == 'true' && runner.os != 'Windows' && steps.setup-pnpm.outputs.store-cache-hit != 'true' }} shell: bash working-directory: ${{ steps.package-manager.outputs.project-dir }} run: | du -sh "${{ steps.setup-pnpm.outputs.store-path }}" || true pnpm store prune du -sh "${{ steps.setup-pnpm.outputs.store-path }}" || true - name: Save pnpm store cache if: ${{ inputs.install-deps == 'true' && inputs.use-actions-cache == 'true' && (inputs.dependency-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true') && inputs.save-actions-cache == 'true' && runner.os != 'Windows' && steps.setup-pnpm.outputs.store-cache-hit != 'true' }} uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ${{ steps.setup-pnpm.outputs.store-path }} key: ${{ steps.setup-pnpm.outputs.store-cache-primary-key }}