From 8082a8d0d68241f29c7f8e72fde19e9e84c844c2 Mon Sep 17 00:00:00 2001 From: Marc Chan Date: Wed, 5 Aug 2026 15:49:49 +0800 Subject: [PATCH] ci: pin pnpm store path across runner fleets (#6414) * ci: pin pnpm store path across runner fleets Nexu ARC resolved the store under _work while hosted/seed used the home store, so actions/cache version hashes diverged and every Nexu job missed. Force $HOME/.pnpm-store and add restore-keys prefix fallback. * test(e2e): lock stable pnpm store path and restore-keys Add regression assertions for the non-persistent home store pin and OS-prefix restore-keys so the cross-fleet cache contract cannot regress silently. Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build) * ci: seed Linux pnpm cache from cache-maintenance Pinning the store path alone cannot hit until a main job saves under the new actions/cache version. Add a single ubuntu-24.04 seed job; the cache is repo-scoped so every Nexu size reuses it. --- .github/actions/setup-workspace/action.yml | 12 +++++ .github/workflows/cache-maintenance.yml | 17 +++++++ e2e/tests/actions-cache-workflows.test.ts | 56 ++++++++++++++++++---- 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/.github/actions/setup-workspace/action.yml b/.github/actions/setup-workspace/action.yml index 60c13f4055..76cc23f44f 100644 --- a/.github/actions/setup-workspace/action.yml +++ b/.github/actions/setup-workspace/action.yml @@ -47,7 +47,17 @@ runs: echo "enabled=true" >> "$GITHUB_OUTPUT" echo "path=$store_dir" >> "$GITHUB_OUTPUT" else + # Force a stable home store path so actions/cache version hashes match + # across fleets (hosted vs Nexu ARC). ARC may copy instead of hardlink; + # that is acceptable for cache hit stability. + store_dir="$HOME/.pnpm-store" + mkdir -p "$store_dir" + { + echo "NPM_CONFIG_STORE_DIR=$store_dir" + echo "npm_config_store_dir=$store_dir" + } >> "$GITHUB_ENV" echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "path=$store_dir" >> "$GITHUB_OUTPUT" fi - name: Setup pnpm @@ -73,6 +83,8 @@ runs: with: path: ${{ steps.pnpm-store.outputs.path }} key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- - name: Install dependencies shell: bash diff --git a/.github/workflows/cache-maintenance.yml b/.github/workflows/cache-maintenance.yml index a3c64d0fdd..9bd0dc874e 100644 --- a/.github/workflows/cache-maintenance.yml +++ b/.github/workflows/cache-maintenance.yml @@ -29,6 +29,23 @@ jobs: with: save-pnpm-cache: 'true' + + # GHA actions/cache is repo-scoped and shared across hosted + every Nexu + # size (small/medium/large). One Linux seed under the pinned home store path + # is enough for the whole fleet; no per-size matrix required. + seed-pnpm-linux: + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout main + uses: actions/checkout@v6.0.2 + + - name: Seed Linux pnpm cache + uses: ./.github/actions/setup-workspace + with: + save-pnpm-cache: 'true' + clean-closed-pr-buildkit: if: github.event_name == 'pull_request_target' && github.repository == 'nexu-io/open-design' runs-on: ubuntu-24.04 diff --git a/e2e/tests/actions-cache-workflows.test.ts b/e2e/tests/actions-cache-workflows.test.ts index 0aa65ae8d9..a0b48155f0 100644 --- a/e2e/tests/actions-cache-workflows.test.ts +++ b/e2e/tests/actions-cache-workflows.test.ts @@ -43,6 +43,33 @@ describe("GitHub Actions cache workflows", () => { action.indexOf("uses: actions/cache/save@v5"), ); + // Non-persistent branch: pin a stable home store before `pnpm store path` + // so actions/cache version hashes match across hosted and Nexu ARC fleets. + const detectStep = sectionBetween( + action, + "- name: Detect persistent pnpm store", + "- name: Setup pnpm", + ); + // Isolate the else arm by its home-store pin (not "else"/"fi", which match + // substrings like npm_config). + const homeStoreIndex = detectStep.indexOf('store_dir="$HOME/.pnpm-store"'); + expect(homeStoreIndex).toBeGreaterThanOrEqual(0); + const nonPersistentBranch = detectStep.slice(homeStoreIndex); + expect(nonPersistentBranch).toContain('echo "NPM_CONFIG_STORE_DIR=$store_dir"'); + expect(nonPersistentBranch).toContain('echo "npm_config_store_dir=$store_dir"'); + expect(nonPersistentBranch).toContain('echo "enabled=false"'); + expect(action.indexOf('store_dir="$HOME/.pnpm-store"')).toBeLessThan( + action.indexOf('run: echo "path=$(pnpm store path --silent)"'), + ); + + const restoreStep = sectionBetween( + action, + "- name: Restore pnpm store", + "- name: Install dependencies", + ); + expect(restoreStep).toContain("restore-keys: |"); + expect(restoreStep).toContain("pnpm-store-${{ runner.os }}-"); + const saveStep = action.slice(action.indexOf("- name: Save pnpm store")); expect(saveStep).toContain("inputs.save-pnpm-cache == 'true'"); expect(saveStep).toContain("steps.persistent-pnpm-store.outputs.enabled != 'true'"); @@ -53,9 +80,10 @@ describe("GitHub Actions cache workflows", () => { expect(saveStep).toContain("github.event_name == 'schedule'"); }); - it("[P1] seeds Windows from main and deletes only closed-PR BuildKit cache families", async () => { + it("[P1] seeds Windows and Linux from main and deletes only closed-PR BuildKit cache families", async () => { const workflow = await readFile(cacheMaintenanceWorkflow, "utf8"); - const seedJob = sectionBetween(workflow, " seed-pnpm-windows:", " clean-closed-pr-buildkit:"); + const windowsSeedJob = sectionBetween(workflow, " seed-pnpm-windows:", " seed-pnpm-linux:"); + const linuxSeedJob = sectionBetween(workflow, " seed-pnpm-linux:", " clean-closed-pr-buildkit:"); const cleanupJob = workflow.slice(workflow.indexOf(" clean-closed-pr-buildkit:")); expect(workflow).toContain("pull_request_target:"); @@ -66,13 +94,19 @@ describe("GitHub Actions cache workflows", () => { expect(workflow).toContain("actions: write"); expect(workflow).toContain("contents: read"); - expect(seedJob).toContain("github.event_name == 'push'"); - expect(seedJob).toContain("github.event_name == 'workflow_dispatch'"); - expect(seedJob).toContain("github.ref == 'refs/heads/main'"); - expect(seedJob).toContain("runs-on: windows-latest"); - expect(seedJob).toContain("uses: actions/checkout@v6.0.2"); - expect(seedJob).toContain("uses: ./.github/actions/setup-workspace"); - expect(seedJob).toContain("save-pnpm-cache: 'true'"); + for (const seedJob of [windowsSeedJob, linuxSeedJob]) { + expect(seedJob).toContain("github.event_name == 'push'"); + expect(seedJob).toContain("github.event_name == 'workflow_dispatch'"); + expect(seedJob).toContain("github.ref == 'refs/heads/main'"); + expect(seedJob).toContain("uses: actions/checkout@v6.0.2"); + expect(seedJob).toContain("uses: ./.github/actions/setup-workspace"); + expect(seedJob).toContain("save-pnpm-cache: 'true'"); + } + + expect(windowsSeedJob).toContain("runs-on: windows-latest"); + // One hosted Linux seed is enough: actions/cache is repo-scoped and shared + // by every Nexu size (small/medium/large) once the store path is pinned. + expect(linuxSeedJob).toContain("runs-on: ubuntu-24.04"); expect(cleanupJob).toContain("github.event_name == 'pull_request_target'"); expect(cleanupJob).toContain("refs/pull/${{ github.event.pull_request.number }}/merge"); @@ -85,7 +119,8 @@ describe("GitHub Actions cache workflows", () => { expect(cleanupJob).not.toContain("github.event.pull_request.head"); }); - it("[P1] uses the existing main visual baseline as the Linux pnpm seed", async () => { + + it("[P1] keeps visual-baseline as a secondary Linux pnpm seed on main", async () => { const workflow = await readFile(visualBaselineWorkflow, "utf8"); const setupStep = sectionBetween(workflow, " - name: Setup workspace", " - name: Setup Playwright"); @@ -95,6 +130,7 @@ describe("GitHub Actions cache workflows", () => { expect(setupStep).toContain("save-pnpm-cache: 'true'"); }); + it("[P1] keeps landing preview caches restore-only before merge and seeds them from main", async () => { const workflow = await readFile(landingPageCiWorkflow, "utf8");