diff --git a/.agents/skills/manage-ci/references/current-inventory.md b/.agents/skills/manage-ci/references/current-inventory.md index 98b6756f55..98b08530fd 100644 --- a/.agents/skills/manage-ci/references/current-inventory.md +++ b/.agents/skills/manage-ci/references/current-inventory.md @@ -397,9 +397,9 @@ warmer image and toolchain epoch, so mismatched native-runtime rows are cold and do not restore it. These four high-fanout families disable per-object GHA publication on every provider. Exact Linux static ABI, Swift ABI, macOS Metal unit ABI, and Windows native ABI caches may publish into GitHub's isolated PR merge-ref -scope for same-PR reruns. The Website slice is the sole publisher for the -shared pnpm key and owns the website npm cache; platform UI producers restore -the pnpm store without racing to save it. Trusted main owns shared publication. +scope for same-PR reruns. UI installs (`ui_quality`, `ui_e2e`, `ui_artifact`) point pnpm at the runner +image's baked store instead of an Actions cache — there is no shared pnpm +key or publisher to race. Trusted main owns shared publication. PR Rust-test, host, native-runtime, product, and platform-check matrices receive `fail_fast: true`; main/manual pass `false`. Quality matrices remain diff --git a/.github/workflows/ci-ui-artifact-slice.yml b/.github/workflows/ci-ui-artifact-slice.yml index dd34da6f2a..498fdf8c66 100644 --- a/.github/workflows/ci-ui-artifact-slice.yml +++ b/.github/workflows/ci-ui-artifact-slice.yml @@ -32,9 +32,6 @@ permissions: contents: read packages: read -env: - CACHE_NAMESPACE: mesh-llm - jobs: runner_policy: name: Select console artifact runner @@ -90,17 +87,20 @@ jobs: with: ref: ${{ inputs.source_sha || github.sha }} persist-credentials: false - - name: Configure isolated pnpm store - run: pnpm config set store-dir ~/.pnpm-store - - name: Restore pnpm store - id: pnpm_cache - if: ${{ needs.runner_policy.outputs.allow_native_github_cache == 'true' }} - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 - with: - path: ~/.pnpm-store - key: ${{ env.CACHE_NAMESPACE }}-pnpm-${{ runner.os }}-${{ hashFiles('crates/mesh-llm-ui/pnpm-lock.yaml', '.github/cache-version.txt') }} - restore-keys: | - ${{ env.CACHE_NAMESPACE }}-pnpm-${{ runner.os }}- + # The container image bakes a warm pnpm store at + # /home/runner/.local/share/pnpm/store (mesh-llm-runner-images + # scripts/warm-dependencies.sh). Point pnpm at it directly instead of + # the Actions cache: containerized jobs fix $HOME to /github/home, so + # neither pnpm's default store-dir resolution nor a plain HOME + # override can find the baked store from in here, and restoring + # ~/.pnpm-store from the Actions cache reported a hit but the files + # didn't land where pnpm looks for them (#1392). Using the image's + # own store sidesteps both and needs no cache round trip at all — this + # also removes the restore-with-no-publisher race this job used to + # have against ui_quality's save (three lanes restored a key only + # ui_quality ever saved). + - name: Point pnpm at the image's baked store + run: pnpm config set store-dir /home/runner/.local/share/pnpm/store - name: Install UI dependencies run: pnpm i --frozen-lockfile - name: Build console distribution diff --git a/.github/workflows/ci-web-slice.yml b/.github/workflows/ci-web-slice.yml index 5591ed3dfa..da7bdb3f58 100644 --- a/.github/workflows/ci-web-slice.yml +++ b/.github/workflows/ci-web-slice.yml @@ -31,9 +31,6 @@ permissions: contents: read packages: read -env: - CACHE_NAMESPACE: mesh-llm - jobs: runner_policy: name: Select website runner @@ -89,24 +86,19 @@ jobs: with: ref: ${{ inputs.source_sha || github.sha }} persist-credentials: false - - name: Configure isolated pnpm store - run: pnpm config set store-dir ~/.pnpm-store - - name: Restore pnpm store - id: pnpm_cache - if: ${{ needs.runner_policy.outputs.allow_native_github_cache == 'true' }} - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 - with: - path: ~/.pnpm-store - key: ${{ env.CACHE_NAMESPACE }}-pnpm-${{ runner.os }}-${{ hashFiles('crates/mesh-llm-ui/pnpm-lock.yaml', '.github/cache-version.txt') }} - restore-keys: ${{ env.CACHE_NAMESPACE }}-pnpm-${{ runner.os }}- + # The container image bakes a warm pnpm store at + # /home/runner/.local/share/pnpm/store (mesh-llm-runner-images + # scripts/warm-dependencies.sh). Point pnpm at it directly instead of + # the Actions cache: containerized jobs fix $HOME to /github/home, so + # neither pnpm's default store-dir resolution nor a plain HOME + # override can find the baked store from in here, and restoring + # ~/.pnpm-store from the Actions cache reported a hit but the files + # didn't land where pnpm looks for them (#1392). Using the image's + # own store sidesteps both and needs no cache round trip at all. + - name: Point pnpm at the image's baked store + run: pnpm config set store-dir /home/runner/.local/share/pnpm/store - name: Install UI dependencies run: pnpm i --frozen-lockfile - - name: Save pnpm store - if: ${{ needs.runner_policy.outputs.allow_native_github_cache == 'true' && steps.pnpm_cache.outputs.cache-hit != 'true' }} - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 - with: - path: ~/.pnpm-store - key: ${{ steps.pnpm_cache.outputs.cache-primary-key }} - name: Lint UI run: pnpm run lint - name: Type-check UI @@ -136,18 +128,10 @@ jobs: with: ref: ${{ inputs.source_sha || github.sha }} persist-credentials: false - - name: Configure isolated pnpm store - run: pnpm config set store-dir ~/.pnpm-store - - name: Restore pnpm store - # ui_quality is this cache entry's one publisher (see its own Save pnpm - # store step). This job only restores it; restore-keys falls back to - # the closest prior entry on a miss instead of racing a second upload. - if: ${{ needs.runner_policy.outputs.allow_native_github_cache == 'true' }} - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 - with: - path: ~/.pnpm-store - key: ${{ env.CACHE_NAMESPACE }}-pnpm-${{ runner.os }}-${{ hashFiles('crates/mesh-llm-ui/pnpm-lock.yaml', '.github/cache-version.txt') }} - restore-keys: ${{ env.CACHE_NAMESPACE }}-pnpm-${{ runner.os }}- + # See ui_quality's "Point pnpm at the image's baked store" step for why + # this doesn't use the Actions cache (#1392). + - name: Point pnpm at the image's baked store + run: pnpm config set store-dir /home/runner/.local/share/pnpm/store - name: Install UI dependencies run: pnpm i --frozen-lockfile - name: Verify prebuilt web environment diff --git a/ci/ci.md b/ci/ci.md index 3b6d45f23c..a93aaeea09 100644 --- a/ci/ci.md +++ b/ci/ci.md @@ -472,7 +472,7 @@ The implemented policy uses that isolation selectively: | Linux Cargo `target` directories | Disabled for Clippy, Rust tests, host, and runtime | Avoids sharded multi-GiB generations and their restore/upload latency | | Static Linux ABI and Swift native ABI | Exact PR-scoped cache on miss | Same-PR reruns reuse the verified native input when its full recipe/toolchain key is unchanged | | macOS Metal unit ABI and Windows native ABI | Exact PR-scoped cache on miss | Same-PR reruns avoid the native rebuild; no restore prefixes cross an ABI boundary | -| Console pnpm store | `ui_quality` is the sole publisher; `ui_e2e` restores only | Avoids the two `ci-web-slice.yml` UI jobs racing to upload the same entry; later same-PR runs reuse a lockfile-keyed store | +| Console pnpm store | None -- `ui_quality`, `ui_e2e`, and `ui_artifact` all point `store-dir` at the runner image's baked pnpm store instead of an Actions cache | Every run installs warm from the image; no cache to publish, restore, or race | | Website npm store | None -- the `website` job runs in the prebuilt `public web` image (baked npm/node) with no bare-metal row, so its `setup-node` cache was deleted outright rather than kept | Every run does a fresh `npm ci`; no cache to invalidate or race | | GitHub artifacts | Never used as cross-run caches | Immutable producers/consumers remain correct within one run; reruns recreate run-scoped artifacts | diff --git a/scripts/tests/test_ci_artifact_actions.py b/scripts/tests/test_ci_artifact_actions.py index 0a53f3eae3..505fb5a780 100644 --- a/scripts/tests/test_ci_artifact_actions.py +++ b/scripts/tests/test_ci_artifact_actions.py @@ -2566,11 +2566,12 @@ def test_dispatched_pr_cache_writes_remain_blocked_with_depot( def test_depot_pr_native_cache_consumers_obey_central_policy(self) -> None: eligible_consumers = { "ci-quality-slice.yml": ("uses: ./.github/actions/restore-sccache-seed",), - "ci-web-slice.yml": ( - "uses: actions/cache/restore@", - "uses: actions/cache/save@", - ), - "ci-ui-artifact-slice.yml": ("uses: actions/cache/restore@",), + # ci-web-slice.yml and ci-ui-artifact-slice.yml have no native + # GitHub cache consumers left: their pnpm jobs (ui_quality, + # ui_e2e, ui_artifact) point store-dir at the runner image's + # baked store instead of the Actions cache (#1392), and + # `website` was already deleted-outright rather than gated (see + # the comment on that job's entry below). "ci-linux-host-slice.yml": ("uses: ./.github/actions/restore-sccache-seed",), "ci-linux-runtime-slice.yml": ("uses: ./.github/actions/restore-sccache-seed",), "ci-rust-tests-slice.yml": ("uses: ./.github/actions/restore-sccache-seed",), @@ -2687,8 +2688,11 @@ def step_block(workflow: str, marker: str) -> str: # image (no bare-metal row), so its setup-node native-cache # consumer was deleted outright rather than gated -- there is # nothing left in that job for the depot/native cache policy to - # govern. See `eligible_consumers["ci-web-slice.yml"]` above for - # the jobs in this file that still participate (ui_quality, ui_e2e). + # govern. The other jobs in that file (ui_quality, ui_e2e) and in + # ci-ui-artifact-slice.yml (ui_artifact) have no native-cache + # consumer left either now that they point at the runner image's + # baked pnpm store instead (#1392); see the comment on + # `eligible_consumers` above. self.assertIn( f"cache: ${{{{ {native_cache_expression} && 'pnpm' || '' }}}}", swift, diff --git a/scripts/tests/test_ci_workflow_artifacts.py b/scripts/tests/test_ci_workflow_artifacts.py index 1e7e71f70a..6818db5ca6 100644 --- a/scripts/tests/test_ci_workflow_artifacts.py +++ b/scripts/tests/test_ci_workflow_artifacts.py @@ -1,4 +1,5 @@ import json +import re from pathlib import Path import unittest @@ -175,9 +176,25 @@ def test_plan_action_emits_real_platform_matrices_and_optional_affected_crates(s def test_ui_cache_and_website_dependencies_are_explicit(self): ui = (ROOT / ".github/workflows/ci-ui-artifact-slice.yml").read_text() web = (ROOT / ".github/workflows/ci-web-slice.yml").read_text() - self.assertIn("uses: actions/cache/restore@", ui) - self.assertNotIn("uses: actions/cache/save@", ui) - self.assertIn("uses: actions/cache/save@", web) + # UI installs point at the runner image's baked pnpm store instead + # of the Actions cache (#1392) -- there is nothing left here to + # restore or save. + # Matches quoted or unquoted `cache: pnpm`/`cache: npm` -- a plain + # substring check would miss `cache: "pnpm"` / `cache: 'npm'`, which + # would still enable setup-node's own dependency cache. + cache_config = re.compile( + r"(?m)^[ \t]*cache:[ \t]*" + r"(?:pnpm|npm|'pnpm'|'npm'|\"pnpm\"|\"npm\")" + r"[ \t]*(?:#.*)?$" + ) + self.assertIn("run: pnpm config set store-dir /home/runner/.local/share/pnpm/store", ui) + self.assertNotIn("uses: actions/cache", ui) + self.assertNotRegex(ui, cache_config) + self.assertNotIn("CACHE_NAMESPACE", ui) + self.assertIn("run: pnpm config set store-dir /home/runner/.local/share/pnpm/store", web) + self.assertNotIn("uses: actions/cache", web) + self.assertNotRegex(web, cache_config) + self.assertNotIn("CACHE_NAMESPACE", web) # The `website` job runs in the prebuilt public-web image with no # bare-metal row, so setup-node's own npm cache and the `just` # install-action were deleted outright (both are baked in the diff --git a/scripts/tests/test_pr_workflow_artifacts.py b/scripts/tests/test_pr_workflow_artifacts.py index 6716d17e94..971fe0ad72 100644 --- a/scripts/tests/test_pr_workflow_artifacts.py +++ b/scripts/tests/test_pr_workflow_artifacts.py @@ -1,4 +1,5 @@ from pathlib import Path +import re import unittest import yaml @@ -26,6 +27,18 @@ class PrWorkflowArtifactTests(unittest.TestCase): def workflow(self, name: str) -> str: return (WORKFLOWS / name).read_text() + def job_blocks(self, workflow: str) -> dict[str, str]: + """Split a workflow's raw text into {job_name: block_text} by + top-level (2-space-indented) job keys under `jobs:`.""" + pattern = re.compile(r"^ ([A-Za-z_][\w-]*):$", re.MULTILINE) + matches = list(pattern.finditer(workflow)) + blocks = {} + for index, match in enumerate(matches): + start = match.end() + end = matches[index + 1].start() if index + 1 < len(matches) else len(workflow) + blocks[match.group(1)] = workflow[start:end] + return blocks + def test_windows_log_store_privacy_checks_are_platform_owned(self): workflow = PLATFORM.read_text() self.assertIn("name: Test Windows log artifact privacy ACL", workflow) @@ -250,8 +263,28 @@ def test_pr_platform_critical_matrices_fail_fast_by_profile(self): def test_pr_cache_publishers_are_exact_and_bounded(self): ui_artifact = self.workflow("ci-ui-artifact-slice.yml") website = self.workflow("ci-web-slice.yml") + # UI installs no longer round-trip through the Actions cache at all + # (#1392): the runner image bakes a warm pnpm store and every pnpm + # job in these two files points store-dir at it directly, so there + # is nothing here to save or restore. self.assertNotIn("name: Save pnpm store", ui_artifact) - self.assertEqual(1, website.count("name: Save pnpm store")) + self.assertNotIn("name: Restore pnpm store", ui_artifact) + self.assertNotIn("actions/cache", ui_artifact) + self.assertNotIn("name: Save pnpm store", website) + self.assertNotIn("name: Restore pnpm store", website) + self.assertNotIn("actions/cache", website) + # Every pnpm job in the two files points store-dir at the image's + # baked store directly: once for ui_artifact, and once each for + # ui_quality and ui_e2e. + store_dir_config = "run: pnpm config set store-dir /home/runner/.local/share/pnpm/store" + self.assertEqual(1, ui_artifact.count(store_dir_config)) + website_jobs = self.job_blocks(website) + for job in ("ui_quality", "ui_e2e"): + self.assertEqual( + 1, + website_jobs[job].count(store_dir_config), + f"expected exactly one baked-store config in {job}", + ) # The `website` job itself runs in the prebuilt public-web image with # no bare-metal row, so it has no native-cache-gated npm consumer # left to publish or bound -- setup-node's own cache was deleted