perf(ci): share trusted caches and fail fast on PR test failures (#3007)

* fix(ci): restore inherited validation failures

* perf(ci): share trusted caches and fail fast on PR test failures

* fix(ci): bound static cache and hash vitest config for knip

- Bound the rolling .turbo/cache snapshot with 7d/500MB eviction so each
  trusted main save cannot accumulate history toward the GitHub limit.
- Hash vitest.config.ts in knip inputs so removing the CiTestSequencer
  import invalidates the cached passing check.
- Cover both in cache-policy and task-hash regressions.
This commit is contained in:
morluto
2026-10-14 04:25:57 +08:00
committed by GitHub
parent 9630c8ff46
commit 0bf01dfa59
27 changed files with 767 additions and 54 deletions
+42
View File
@@ -0,0 +1,42 @@
name: Cache npm package downloads
description: Restore npm's download cache and save it only from trusted default-branch events.
outputs:
can-save:
description: Whether this run may update the shared npm download cache.
value: ${{ steps.context.outputs.can-save }}
path:
description: The npm download-cache directory.
value: ${{ steps.context.outputs.path }}
runs:
using: composite
steps:
- id: context
shell: bash
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
LOCKFILES_HASH: ${{ hashFiles('package-lock.json', 'npm-shrinkwrap.json', '.npmrc') }}
run: |
cache_path="$(npm config get cache)"
runtime_arch="$(node -p 'process.arch')"
can_save=false
if [[ -n "${DEFAULT_BRANCH}" && "${GITHUB_REF}" == "refs/heads/${DEFAULT_BRANCH}" && ( "${GITHUB_EVENT_NAME}" == "push" || "${GITHUB_EVENT_NAME}" == "schedule" || "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ) ]]; then
can_save=true
fi
cache_key="npm-download-${RUNNER_OS}-${runtime_arch}-${LOCKFILES_HASH}"
{
printf 'can-save=%s\n' "${can_save}"
printf 'path=%s\n' "${cache_path}"
printf 'key=%s\n' "${cache_key}"
} >> "${GITHUB_OUTPUT}"
- name: Restore and save npm package downloads
if: steps.context.outputs.can-save == 'true'
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.context.outputs.path }}
key: ${{ steps.context.outputs.key }}
- name: Restore npm package downloads without saving
if: steps.context.outputs.can-save != 'true'
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.context.outputs.path }}
key: ${{ steps.context.outputs.key }}
+49 -15
View File
@@ -122,8 +122,19 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- name: Restore static task cache
if: needs.changes.outputs.code == 'true'
id: static-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: .turbo/cache/
key: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-${{ github.sha }}
# Turbo validates each task's inputs after restoring this snapshot.
restore-keys: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-
- name: Validate workflow syntax and expressions
if: needs.changes.outputs.workflows == 'true'
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes=
@@ -136,6 +147,15 @@ jobs:
run: npm run test:python
- if: needs.changes.outputs.code != 'true'
run: npm run format:check
- name: Save validated static task cache
if: >-
success() && needs.changes.outputs.code == 'true' &&
steps.npm-cache.outputs.can-save == 'true' &&
steps.static-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: .turbo/cache/
key: ${{ steps.static-cache.outputs.cache-primary-key }}
- name: Upload complete static diagnostics
if: always() && needs.changes.outputs.code == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
@@ -153,7 +173,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
# Failed PRs release the remaining matrix slots; qualification retains
# every shard's diagnostics and coverage. Passing PRs run the full suite.
fail-fast: ${{ github.event_name == 'pull_request' && needs.changes.outputs.tier == 'fast' }}
matrix:
shard: [1/6, 2/6, 3/6, 4/6, 5/6, 6/6]
steps:
@@ -161,7 +183,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
@@ -212,7 +236,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- name: Reuse portable build outputs across runs
uses: ./.github/actions/portable-build-cache
@@ -286,7 +312,10 @@ jobs:
if: needs.changes.outputs.code == 'true' && success()
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
if: needs.changes.outputs.code == 'true' && success()
id: npm-cache
uses: ./.github/actions/npm-cache
- if: needs.changes.outputs.code == 'true' && success()
run: npm ci
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
@@ -450,7 +479,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- name: Verify native runtime identity
env:
EXPECTED_CI_PLATFORM: ${{ matrix.platform }}
@@ -472,11 +503,6 @@ jobs:
- run: npm run verify:windows-native:artifact
- name: Verify NativeAOT diagnostic applicability on this host
run: npm run test:focused -- tests/evaluation/package/nativeAotDiagnostics.test.ts
- name: Resolve npm cache before verifier HOME isolation
id: npm-cache
run: |
cache_path="$(npm config get cache)"
echo "path=${cache_path}" >> "${GITHUB_OUTPUT}"
- run: npm run verify:package
env:
npm_config_cache: ${{ steps.npm-cache.outputs.path }}
@@ -506,7 +532,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
@@ -554,7 +582,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- name: Run the public CLI/MCP reconstruction journey
env:
@@ -583,7 +613,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
@@ -703,7 +735,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- run: npm run docs:check
- name: Generation leaves authored files unchanged
+3 -1
View File
@@ -23,6 +23,8 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- run: npm run docs:build
+3 -1
View File
@@ -32,7 +32,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
+3 -1
View File
@@ -25,7 +25,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
+3 -1
View File
@@ -26,7 +26,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
+3 -1
View File
@@ -48,7 +48,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: "1.26.1"
+6 -2
View File
@@ -19,7 +19,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- name: Pack CLI for setup verification
shell: bash
@@ -40,7 +42,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- name: Install packed CLI in isolation
shell: bash
+3 -1
View File
@@ -19,7 +19,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- name: Install packed CLI in isolation
shell: bash
@@ -27,7 +27,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
+3 -1
View File
@@ -47,7 +47,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y tshark util-linux python3
- run: npm ci
- run: npm run build:cached
+3 -1
View File
@@ -29,7 +29,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
+3 -1
View File
@@ -40,7 +40,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: sudo apt-get update && sudo apt-get install -y sigrok-cli util-linux python3
- run: npm ci
- run: npm run build:cached
@@ -26,7 +26,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- name: Use pinned package manager
run: npm install --global npm@11.16.0
- run: npm ci
+3 -1
View File
@@ -21,7 +21,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
+3 -1
View File
@@ -21,7 +21,9 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
+3 -1
View File
@@ -27,7 +27,9 @@ jobs:
with:
node-version: ${{ matrix.node }}
check-latest: true
cache: npm
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
+1
View File
@@ -141,6 +141,7 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç
| Yerel makine kodu içeren ikili dosyalar | Sözde kod, assembly kodu, dizeler, semboller, çağrılar ve referanslar | Hopper, Ghidra veya IDA; [yerel kod analizi](https://rea.tools/tr/guides/native/) |
| Çevrimdışı ELF yapısı | Bölümler, segmentler, özgün semboller/yeniden konumlandırmalar ve statik analizden olası güvenlik önlemleri | Linux x64 üzerinde çağıranın sağladığı pwntools; [ikili dosya tanılama](../binary-diagnostics.md) |
| Go ikili dosyaları | Gömülü compiler sürümü, modüller, replacement'lar ve build ayarları | Yerleşik statik ELF/PE/thin Mach-O incelemesi; Go kurulumu gerekmez |
| Rust ikili dosyaları | Gömülü cargo-auditable crate adları, sürümleri, kaynakları ve bağımlılık ilişkileri | Yerleşik statik ELF/PE/thin Mach-O incelemesi; [Rust ikili dosyaları](../rust-binaries.md) |
| WASM artefaktı | Doğrulama, bölümler, tam import/export WAT biçimleri ve decode edilmiş WAT | Çağıranın sağladığı WABT 1.0.42; `inspect_wasm_artifact` |
| EVM bayt kodu | Yönlendirme seçicileri, bayt ofsetleri, çıkarımla belirlenen argümanlar ve durum değiştirilebilirliği | Linux x64 üzerinde yerel ham bayt/onaltılık girdi; [çevrimdışı EVM rehberi](../evm-bytecode.md) |
| SQLite veritabanları | Tablolar, sütunlar, index'ler, view'lar, trigger'lar ve seçilen sıradan tablo satırları | Yerel database/WAL snapshot'ı; native SQLite sınırlarıyla Node.js 24.x >=24.15 veya 26+ |
+28 -5
View File
@@ -887,8 +887,13 @@ successful ancestor push run of this CI workflow, rather than the preceding
push: changes from failed or cancelled intermediate runs remain in scope.
Missing, untrusted, or non-ancestor history selects every lane. The lookup is
bounded to the last 100 successful push runs; lookup failures broaden selection.
Every code change still runs all six portable shards, without coverage
instrumentation in this fast tier. This deliberately avoids relying on an
Every code change still selects all six portable shards, without coverage
instrumentation in this fast tier. Ordinary PR matrices cancel remaining
shards after the first failure; passing PRs execute the complete suite. Main,
`ci:full`, scheduled, manual, and release qualification retain every shard's
results after a failure. Available failed-shard reports remain diagnostic only:
failed, cancelled, skipped, or missing selected shards cannot pass the gate or
publish successful timing history. This deliberately avoids relying on an
import graph for generated files, filesystem inputs, subprocesses, or bridges.
Scheduled qualification is admitted hourly at minute 23. A forced full run at
@@ -916,10 +921,11 @@ classification job records its selection and broad-fallback reasons in the run
summary. Representative Git-diff cases live in
`tests/boundary/filesystem/ciChangeScope.test.ts`.
`CI required` always runs and checks classification and every selected lane,
`CI required` checks classification and every selected lane,
rejecting failed, cancelled, or unexpectedly skipped work. It rejects missing or
unknown tiers and requires every canonical scope to be explicitly `true` or
`false`; incomplete classification cannot silently omit verification lanes.
The aggregate remains cancellable so superseded runs release their slots.
Branch protection
should require this aggregate, `Static checks`, `Portable tests`, and
`Conventional PR title`. Title validation runs separately on PR opening,
@@ -968,7 +974,7 @@ filename cases.
POSIX package verification resolves the runner's configured npm cache before
creating disposable client homes and passes that location to its child installs.
This reuses the download cache restored by `setup-node` while keeping client
This reuses the download cache restored by the shared npm-cache action while keeping client
configuration isolated. Installs retain their existing registry and integrity
checks.
@@ -998,7 +1004,24 @@ thresholds remain enforced in full qualification.
Missing or unusable history falls back to Vitest's default assignment. Only
timing data is reused; live provider results are never cached. Turbo outputs are
restored by task hash, with trusted default-branch runs as cache writers and PRs
as restore-only consumers. npm caches hold downloads, not `node_modules`.
as restore-only consumers. The static job also restores its Turbo snapshot;
each task revalidates its own input hash before reusing outputs or diagnostics.
The Node pin and npm install settings are global hash inputs. Knip includes its ignore rules and VitePress
entry, so changes to those inputs cannot reuse an older successful check.
The shared npm-cache action caches downloads, not `node_modules`, and every
consumer still runs `npm ci`. Keys cover the runner OS, actual Node architecture,
root lockfiles, and `.npmrc`. Only push, schedule, and manual events on the
repository's default branch may save shared npm caches; PRs restore only.
Static snapshots use the same write authority and are saved only after the
static job's validation succeeds. Main warms the new cache namespace after
merge; a cold cache or changed lockfile falls back to the normal install.
This prevents each PR from saving another branch-scoped copy of the same
download cache. It does not remove runner assignment waits.
Portable owner-based test selection remains observational. Its reports compare
proposed omissions with the full run; incomplete or skipped observations do not
establish that fewer tests would preserve regression detection.
The eight automatic browser, binary-layout, EVM, JavaScript-recovery,
recorded-crash, and web provider lanes download `rea-build-cache` from their own
+2
View File
@@ -331,7 +331,9 @@ describe("managed operand extent diagnostics", () => {
});
},
);
});
describe("managed complete operand decoding", () => {
// The extent check must also reject nothing that fits, so every multi-byte
// width is exercised at exactly its own size with a known value. Each body
// ends at its operand, so a width declared too large turns a valid operand
@@ -443,7 +443,9 @@ describe("artifact comparison completeness", () => {
changes: [],
});
}, 15_000);
});
describe("artifact comparison fragment integrity", () => {
it("assembles real inventory fragments and retains every citation", async () => {
const parent = await createTestTempDirectory("rea-artifact-fragments-");
const leftPath = join(parent, "left.app");
@@ -0,0 +1,252 @@
import { execFileSync } from "node:child_process";
import { chmod, readFile, readdir, writeFile } from "node:fs/promises";
import { delimiter, join } from "node:path";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
import { z } from "zod";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const repository = process.cwd();
const actionPath = join(repository, ".github/actions/npm-cache/action.yml");
const readAction = async () =>
z
.object({
outputs: z.record(z.string(), z.object({ value: z.string() })),
runs: z.object({
steps: z.array(
z.object({
id: z.string().optional(),
if: z.string().optional(),
uses: z.string().optional(),
with: z.record(z.string(), z.string()).optional(),
run: z.string().optional(),
env: z.record(z.string(), z.string()).optional(),
}),
),
}),
})
.parse(parse(await readFile(actionPath, "utf8")));
const readWorkflows = async () => {
const directory = join(repository, ".github/workflows");
const files = (await readdir(directory)).filter((file) =>
/\.ya?ml$/u.test(file),
);
return Promise.all(
files.map(async (file) => ({
file,
workflow: z
.object({
jobs: z
.record(
z.string(),
z.object({
steps: z.array(z.record(z.string(), z.unknown())).optional(),
}),
)
.optional(),
})
.parse(parse(await readFile(join(directory, file), "utf8"))),
})),
);
};
describe("hosted npm download-cache policy", () => {
it("only allows shared writes for trusted original default-branch events", async () => {
const action = await readAction();
const policy = action.runs.steps.find((step) => step.id === "context");
const command = z.string().parse(policy?.run);
const outputDirectory = await createTestTempDirectory(
"rea-ci-cache-policy-",
);
const npmStub = join(outputDirectory, "npm");
await writeFile(
npmStub,
String.raw`#!/bin/sh
printf '%s\n' 'C:\Users\runner with spaces\AppData\Local\npm-cache'
`,
);
await chmod(npmStub, 0o755);
for (const [event, ref, defaultBranch, expected] of [
["push", "refs/heads/main", "main", "true"],
["schedule", "refs/heads/main", "main", "true"],
["workflow_dispatch", "refs/heads/main", "main", "true"],
["push", "refs/heads/stable", "stable", "true"],
["pull_request", "refs/heads/main", "main", "false"],
["pull_request_target", "refs/heads/main", "main", "false"],
["workflow_call", "refs/heads/main", "main", "false"],
["push", "refs/heads/feature/cache", "main", "false"],
["push", "refs/heads/main", "stable", "false"],
["push", "refs/heads/main", "", "false"],
]) {
const outputPath = join(
outputDirectory,
`policy-${Date.now()}-${Math.random()}`,
);
execFileSync("bash", ["-euo", "pipefail", "-c", command], {
env: {
...process.env,
PATH: `${outputDirectory}${delimiter}${process.env.PATH ?? ""}`,
GITHUB_EVENT_NAME: event,
GITHUB_REF: ref,
GITHUB_OUTPUT: outputPath,
DEFAULT_BRANCH: defaultBranch,
LOCKFILES_HASH: "lock-hash",
RUNNER_OS: "Windows",
},
});
const output = await readFile(outputPath, "utf8");
expect(output).toContain(`can-save=${expected}\n`);
expect(output).toContain(
`path=${String.raw`C:\Users\runner with spaces\AppData\Local\npm-cache`}\n`,
);
expect(output).toContain(
`key=npm-download-Windows-${process.arch}-lock-hash\n`,
);
}
});
it("uses a lockfile-, npmrc-, OS-, and runtime-architecture-scoped download cache", async () => {
const action = await readAction();
const policy = action.runs.steps.find((step) => step.id === "context");
const restoreAndSave = action.runs.steps.find((step) =>
step.uses?.startsWith("actions/cache@"),
);
const restoreOnly = action.runs.steps.find((step) =>
step.uses?.startsWith("actions/cache/restore@"),
);
expect(action.outputs["can-save"]?.value).toBe(
"${{ steps.context.outputs.can-save }}",
);
expect(action.outputs.path?.value).toBe(
"${{ steps.context.outputs.path }}",
);
expect(policy?.env?.DEFAULT_BRANCH).toBe(
"${{ github.event.repository.default_branch }}",
);
expect(policy?.env?.LOCKFILES_HASH).toBe(
"${{ hashFiles('package-lock.json', 'npm-shrinkwrap.json', '.npmrc') }}",
);
expect(policy?.run).toContain(
'cache_key="npm-download-${RUNNER_OS}-${runtime_arch}-${LOCKFILES_HASH}"',
);
expect(restoreAndSave?.uses).toBe(
"actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830",
);
expect(restoreAndSave?.if).toBe("steps.context.outputs.can-save == 'true'");
expect(restoreOnly?.uses).toBe(
"actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830",
);
expect(restoreOnly?.if).toBe("steps.context.outputs.can-save != 'true'");
expect(restoreAndSave?.with?.path).toBe(
"${{ steps.context.outputs.path }}",
);
expect(restoreOnly?.with?.path).toBe(restoreAndSave?.with?.path);
expect(restoreAndSave?.with?.key).toBe("${{ steps.context.outputs.key }}");
expect(restoreOnly?.with?.key).toBe(restoreAndSave?.with?.key);
expect(restoreAndSave?.with).not.toHaveProperty("restore-keys");
expect(restoreOnly?.with).not.toHaveProperty("restore-keys");
});
it("replaces every inline setup-node npm cache immediately and preserves conditions", async () => {
const workflows = await readWorkflows();
let npmCacheSteps = 0;
for (const { file, workflow } of workflows) {
const source = await readFile(
join(repository, ".github/workflows", file),
"utf8",
);
expect(source).not.toMatch(/^\s+cache:\s*npm\s*$/mu);
for (const [jobName, job] of Object.entries(workflow.jobs ?? {})) {
const steps = job.steps ?? [];
for (let index = 0; index < steps.length; index++) {
const step = steps[index];
if (step?.uses !== "./.github/actions/npm-cache") continue;
npmCacheSteps++;
expect(step.id).toBe("npm-cache");
const setup = steps[index - 1];
expect(
typeof setup?.uses === "string" &&
setup.uses.startsWith("actions/setup-node@"),
`${file}:${jobName} npm cache action must immediately follow setup-node`,
).toBe(true);
expect(step.if).toBe(setup?.if);
expect(setup?.with).not.toHaveProperty("cache", "npm");
const npmCiIndex = steps.findIndex(
(candidate, candidateIndex) =>
candidateIndex > index && candidate.run === "npm ci",
);
expect(
npmCiIndex,
`${file}:${jobName} retains npm ci after cache`,
).toBeGreaterThan(index);
}
}
}
expect(npmCacheSteps).toBeGreaterThan(0);
});
});
it("shares static task snapshots only after validation using the npm cache owner's write authority", async () => {
const workflows = await readWorkflows();
const ci = workflows.find(({ file }) => file === "ci.yml");
const steps = ci?.workflow.jobs?.static?.steps ?? [];
const restore = steps.find((step) => step.id === "static-cache");
const save = steps.find(
(step) => step.name === "Save validated static task cache",
);
expect(restore?.uses).toBe(
"actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830",
);
expect(restore?.if).toBe("needs.changes.outputs.code == 'true'");
expect(restore?.with).toMatchObject({ path: ".turbo/cache/" });
expect(save?.uses).toBe(
"actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830",
);
expect(save?.if).toContain("success()");
expect(save?.if).toContain("steps.npm-cache.outputs.can-save == 'true'");
expect(save?.with).toEqual({
path: ".turbo/cache/",
key: "${{ steps.static-cache.outputs.cache-primary-key }}",
});
expect(steps.indexOf(save!)).toBeGreaterThan(
steps.findIndex((step) => step.run === "npm run test:python"),
);
});
it("bounds the rolling static snapshot with Turbo age and size eviction", async () => {
const turbo = z
.object({
cacheMaxAge: z.string(),
cacheMaxSize: z.string(),
tasks: z.record(
z.string(),
z.object({ inputs: z.array(z.unknown()).optional() }),
),
})
.parse(JSON.parse(await readFile(join(repository, "turbo.json"), "utf8")));
// The static job restores the previous snapshot by prefix and saves the
// whole .turbo/cache directory again. Without local eviction every main
// commit carries all historical entries into a new immutable GitHub cache,
// growing transfers toward the 10 GB repository limit.
expect(turbo.cacheMaxAge).toMatch(/^[1-9]\d*(s|m|h|d|w)$/u);
expect(turbo.cacheMaxAge).not.toBe("0");
const size = /^(\d+(?:\.\d+)?)(MB|GB|TB)$/iu.exec(turbo.cacheMaxSize);
expect(size, "cacheMaxSize must use an explicit MB/GB/TB budget").not.toBe(
null,
);
const value = Number(size?.[1] ?? "0");
const unit = (size?.[2] ?? "MB").toUpperCase();
const megabytes =
unit === "TB" ? value * 1024 * 1024 : unit === "GB" ? value * 1024 : value;
expect(megabytes).toBeGreaterThan(0);
expect(megabytes).toBeLessThanOrEqual(1024);
expect(turbo.tasks["knip"]?.inputs).toContain("vitest.config.ts");
});
@@ -506,3 +506,44 @@ it("rejects incomplete or malformed classification even when available jobs succ
});
}
});
it("fails fast only for ordinary PR matrices and rejects partial portable feedback", async () => {
const { jobs } = await workflow();
expect(jobs["test-shard"]?.strategy).toMatchObject({
"fail-fast":
"${{ github.event_name == 'pull_request' && needs.changes.outputs.tier == 'fast' }}",
});
expect(jobs["package-e2e"]?.strategy).toMatchObject({ "fail-fast": false });
const flags = await scopeFlags();
for (const result of ["failure", "cancelled", "skipped", "missing"]) {
// Even an incorrectly successful report merge cannot hide matrix failure.
const needs: Record<string, unknown> = {
changes: {
result: "success",
outputs: {
...Object.fromEntries(flags.map((flag) => [flag, "false"])),
tier: "fast",
code: "true",
},
},
static: { result: "success" },
build: { result: "success" },
test: { result: "success" },
...(result === "missing" ? {} : { "test-shard": { result } }),
};
await expect(
executeWorkflowFixture(
process.execPath,
[
fileURLToPath(
new URL("../../../scripts/ci/required.mjs", import.meta.url),
),
],
{ env: { ...process.env, CI_JOBS: JSON.stringify(needs) } },
),
).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("test-shard"),
});
}
});
@@ -0,0 +1,290 @@
import { spawnSync } from "node:child_process";
import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { workflowGit } from "../../support/workflowGit.js";
const repository = fileURLToPath(new URL("../../../", import.meta.url));
const turbo = join(repository, "node_modules/turbo/bin/turbo");
const reportScript = join(repository, "scripts/ci/static-check-report.mjs");
const rootConfigSchema = z.object({
globalDependencies: z.array(z.string()).optional(),
globalPassThroughEnv: z.array(z.string()).optional(),
tasks: z.record(z.string(), z.record(z.string(), z.unknown())),
});
const dryRunSchema = z.object({
tasks: z.array(z.object({ taskId: z.string(), hash: z.string() })),
});
const execute = (command: string, args: string[], cwd: string) =>
spawnSync(process.execPath, [command, ...args], {
cwd,
encoding: "utf8",
timeout: 30_000,
maxBuffer: 16 * 1024 * 1024,
env: process.env,
});
const readRootConfig = async () =>
rootConfigSchema.parse(
JSON.parse(await readFile(join(repository, "turbo.json"), "utf8")),
);
const writeTaskFixture = async (
root: string,
taskNames: string[],
scripts: Record<string, string>,
) => {
const config = await readRootConfig();
const tasks = Object.fromEntries(
taskNames.map((name) => {
const task = config.tasks[name];
if (task === undefined) throw new Error(`Missing Turbo task ${name}`);
// These tests measure a task's own inputs. Drop dependency edges so the
// unrelated compile task cannot add fixture-specific inputs or outputs.
const { dependsOn: _dependsOn, ...definition } = task;
return [name, definition];
}),
);
await writeFile(
join(root, "turbo.json"),
JSON.stringify(
{
...(config.globalDependencies === undefined
? {}
: { globalDependencies: config.globalDependencies }),
...(config.globalPassThroughEnv === undefined
? {}
: { globalPassThroughEnv: config.globalPassThroughEnv }),
tasks,
},
null,
2,
),
);
await writeFile(
join(root, "package.json"),
JSON.stringify({
name: "rea-static-task-cache-fixture",
version: "1.0.0",
private: true,
packageManager: "npm@11.16.0",
scripts,
}),
);
await writeFile(
join(root, "package-lock.json"),
JSON.stringify({
name: "rea-static-task-cache-fixture",
version: "1.0.0",
lockfileVersion: 3,
packages: {
"": { name: "rea-static-task-cache-fixture", version: "1.0.0" },
},
}),
);
};
const runTurbo = (
root: string,
task: string,
cacheDirectory: string,
dryRun = false,
) =>
execute(
turbo,
[
"run",
task,
...(dryRun ? ["--dry=json"] : ["--output-logs=errors-only"]),
"--cache=local:rw,remote:r",
"--cache-dir",
cacheDirectory,
],
root,
);
const taskHash = (root: string, task: string, cacheDirectory: string) => {
const result = runTurbo(root, task, cacheDirectory, true);
expect(result.error).toBeUndefined();
expect(result.status, result.stderr).toBe(0);
const { tasks } = dryRunSchema.parse(JSON.parse(result.stdout));
const entry = tasks.find(({ taskId }) => taskId === task);
if (entry === undefined) throw new Error(`Turbo omitted task ${task}`);
return entry.hash;
};
it("restores a real lint report across cold, warm, and fresh consumer checkouts", async () => {
const producer = await createTestTempDirectory("rea-static-cache-producer-");
const consumer = await createTestTempDirectory("rea-static-cache-consumer-");
const cache = await createTestTempDirectory("rea-static-cache-store-");
const script = "node scripts/ci/static-check-report.mjs lint";
for (const root of [producer, consumer]) {
await mkdir(join(root, "scripts/ci"), { recursive: true });
await mkdir(join(root, "src"), { recursive: true });
await symlink(
join(repository, "node_modules"),
join(root, "node_modules"),
"junction",
);
await writeTaskFixture(root, ["lint"], { lint: script });
await writeFile(
join(root, ".nvmrc"),
await readFile(join(repository, ".nvmrc")),
);
await writeFile(join(root, ".gitignore"), ".cache/\nnode_modules/\n");
await writeFile(
join(root, ".oxlintrc.json"),
JSON.stringify({ rules: { "no-debugger": "error" } }),
);
await writeFile(join(root, "src/check.js"), "export const ready = true;\n");
await writeFile(
join(root, "scripts/ci/static-check-report.mjs"),
await readFile(reportScript, "utf8"),
);
await workflowGit(root, ["init", "--initial-branch=main"]);
await workflowGit(root, ["add", "."]);
await workflowGit(root, ["commit", "-m", "test: seed static task cache"]);
}
const cold = runTurbo(producer, "lint", cache);
expect(cold.error).toBeUndefined();
expect(cold.status, cold.stderr).toBe(0);
expect(cold.stdout).toMatch(/Cached:\s+0 cached/u);
const report = join(producer, ".cache/static-checks/oxlint.json");
const cleanReport = await readFile(report, "utf8");
await rm(join(producer, ".cache"), { recursive: true });
const warm = runTurbo(producer, "lint", cache);
expect(warm.error).toBeUndefined();
expect(warm.status, warm.stderr).toBe(0);
expect(warm.stdout).toMatch(/Cached:\s+1 cached/u);
expect(await readFile(report, "utf8")).toBe(cleanReport);
const freshConsumer = runTurbo(consumer, "lint", cache);
expect(freshConsumer.error).toBeUndefined();
expect(freshConsumer.status, freshConsumer.stderr).toBe(0);
expect(freshConsumer.stdout).toMatch(/Cached:\s+1 cached/u);
expect(
await readFile(join(consumer, ".cache/static-checks/oxlint.json"), "utf8"),
).toBe(cleanReport);
await writeFile(join(consumer, "src/check.js"), "debugger;\n");
const invalid = runTurbo(consumer, "lint", cache);
expect(invalid.error).toBeUndefined();
expect(invalid.status).not.toBe(0);
expect(invalid.stdout).toMatch(/Cached:\s+0 cached/u);
const { diagnostics } = z
.object({
diagnostics: z.array(
z.object({ code: z.string().optional(), severity: z.string() }),
),
})
.parse(
JSON.parse(
await readFile(
join(consumer, ".cache/static-checks/oxlint.json"),
"utf8",
),
),
);
expect(diagnostics).toContainEqual(
expect.objectContaining({
code: "eslint(no-debugger)",
severity: "error",
}),
);
});
it("invalidates checker task hashes for Node, ignore, and VitePress config changes", async () => {
const root = await createTestTempDirectory("rea-static-cache-inputs-");
const cache = await createTestTempDirectory("rea-static-cache-hashes-");
await mkdir(join(root, "docs/.vitepress"), { recursive: true });
await mkdir(join(root, "src"), { recursive: true });
await mkdir(join(root, "tests"), { recursive: true });
await writeTaskFixture(
root,
["jscpd", "knip", "metadata:check", "scan:todos"],
// This fixture probes only Turbo's real task hash; the checker commands
// are intentionally inert so the hash test cannot invoke large suites.
{
jscpd: 'node -e "process.exit(0)"',
knip: 'node -e "process.exit(0)"',
"metadata:check": 'node -e "process.exit(0)"',
"scan:todos": 'node -e "process.exit(0)"',
},
);
await writeFile(join(root, ".nvmrc"), "24.18.0\n");
await writeFile(join(root, ".npmrc"), "engine-strict=true\n");
await writeFile(join(root, ".gitignore"), ".cache/\n");
await writeFile(join(root, ".jscpd.json"), "{}\n");
await writeFile(join(root, "docs/.vitepress/config.ts"), "export {};\n");
await writeFile(
join(root, "vitest.config.ts"),
'import { CiTestSequencer } from "./tests/ciTestSequencer.js";\nexport const sequencer = CiTestSequencer;\n',
);
await writeFile(join(root, "src/input.ts"), "export {};\n");
await writeFile(join(root, "tests/input.ts"), "export {};\n");
const initial = new Map<string, string>();
for (const task of ["jscpd", "knip", "metadata:check", "scan:todos"])
initial.set(task, taskHash(root, task, cache));
await writeFile(join(root, ".nvmrc"), "24.19.0\n");
for (const task of ["jscpd", "metadata:check", "scan:todos"])
expect(
taskHash(root, task, cache),
`${task} must hash the Node pin`,
).not.toBe(initial.get(task));
await writeFile(join(root, ".nvmrc"), "24.18.0\n");
await writeFile(join(root, ".npmrc"), "engine-strict=false\n");
for (const task of ["jscpd", "knip", "metadata:check", "scan:todos"])
expect(
taskHash(root, task, cache),
`${task} must hash npm settings`,
).not.toBe(initial.get(task));
await writeFile(join(root, ".npmrc"), "engine-strict=true\n");
for (const task of ["jscpd", "metadata:check", "scan:todos"])
expect(taskHash(root, task, cache), `${task} restores after Node pin`).toBe(
initial.get(task),
);
await writeFile(join(root, ".gitignore"), ".cache/\ncoverage/\n");
for (const task of ["jscpd", "knip"])
expect(taskHash(root, task, cache), `${task} must hash gitignore`).not.toBe(
initial.get(task),
);
await writeFile(join(root, ".gitignore"), ".cache/\n");
for (const task of ["jscpd", "knip"])
expect(
taskHash(root, task, cache),
`${task} restores after gitignore`,
).toBe(initial.get(task));
await writeFile(
join(root, "docs/.vitepress/config.ts"),
"export const configRevision = 2;\n",
);
expect(
taskHash(root, "knip", cache),
"knip must hash its configured VitePress entry",
).not.toBe(initial.get("knip"));
await writeFile(join(root, "docs/.vitepress/config.ts"), "export {};\n");
await writeFile(join(root, "vitest.config.ts"), "export {};\n");
expect(
taskHash(root, "knip", cache),
"knip must hash vitest.config.ts so unused sequencer imports invalidate the cache",
).not.toBe(initial.get("knip"));
for (const task of ["jscpd", "metadata:check", "scan:todos"])
expect(
taskHash(root, task, cache),
`${task} must ignore vitest.config.ts`,
).toBe(initial.get(task));
});
@@ -343,9 +343,7 @@ describe("overview document identity through MCP", () => {
details: {
provider_id: "fixture",
operation: "list_documents",
reason: expect.stringContaining(
"provider lacks document selection",
),
reason: expect.stringContaining("provider lacks document selection"),
},
});
});
@@ -25,7 +25,10 @@ const execution = (failure: unknown, status = 1) => ({
stdout: JSON.stringify(failure),
});
const overview = (procedure_count?: unknown, operation = "binary_overview") => ({
const overview = (
procedure_count?: unknown,
operation = "binary_overview",
) => ({
operation,
...(procedure_count === undefined
? {}
+7 -14
View File
@@ -1,11 +1,13 @@
{
"$schema": "https://turborepo.dev/schema.json",
"cacheMaxAge": "7d",
"cacheMaxSize": "500MB",
"globalDependencies": [".nvmrc", ".npmrc"],
"globalPassThroughEnv": ["GOMAXPROCS", "GOMEMLIMIT", "UV_THREADPOOL_SIZE"],
"tasks": {
"compile": {
"dependsOn": ["metadata:check"],
"inputs": [
".nvmrc",
"package.json",
"package-lock.json",
"scripts/check-dependency-install.mjs",
@@ -22,7 +24,6 @@
},
"typecheck": {
"inputs": [
".nvmrc",
"package.json",
"package-lock.json",
"src/**/*.ts",
@@ -49,7 +50,6 @@
"outputs": [".cache/static-checks/oxlint.json"],
"inputs": [
".gitignore",
".nvmrc",
".oxlintrc.json",
"package.json",
"package-lock.json",
@@ -71,7 +71,6 @@
"outputs": [".cache/static-checks/oxlint.json"],
"inputs": [
".gitignore",
".nvmrc",
".oxlintrc.json",
"package.json",
"package-lock.json",
@@ -92,7 +91,7 @@
"knip": {
"dependsOn": ["compile"],
"inputs": [
".nvmrc",
".gitignore",
"knip.json",
"package.json",
"package-lock.json",
@@ -100,7 +99,9 @@
"src/**/*.ts",
"tests/**/*.ts",
"tsconfig.json",
"tsconfig.build.json"
"tsconfig.build.json",
"vitest.config.ts",
"docs/.vitepress/config.ts"
]
},
"jscpd": {
@@ -139,7 +140,6 @@
"artifacts:skills"
],
"inputs": [
".nvmrc",
"package.json",
"package-lock.json",
"scripts/generate-*.mjs",
@@ -171,7 +171,6 @@
"artifacts:mcp-catalog": {
"dependsOn": ["compile"],
"inputs": [
".nvmrc",
"package.json",
"package-lock.json",
"scripts/generate-mcp-tool-catalog.mjs",
@@ -182,7 +181,6 @@
"artifacts:skills": {
"dependsOn": ["compile"],
"inputs": [
".nvmrc",
"package.json",
"package-lock.json",
"scripts/generate-skill-metadata.mjs",
@@ -194,7 +192,6 @@
"artifacts:product-catalog": {
"dependsOn": ["compile"],
"inputs": [
".nvmrc",
"package.json",
"package-lock.json",
"scripts/generate-product-catalog.mjs",
@@ -206,7 +203,6 @@
"artifacts:managed-evidence": {
"dependsOn": ["artifacts:skills"],
"inputs": [
".nvmrc",
"package.json",
"package-lock.json",
"scripts/generate-completion-ledger.mjs",
@@ -222,7 +218,6 @@
"artifacts:error-schema": {
"dependsOn": ["compile"],
"inputs": [
".nvmrc",
"package.json",
"package-lock.json",
"scripts/generate-error-schema.mjs",
@@ -235,7 +230,6 @@
"mcp-catalog:check:built": {
"dependsOn": ["artifacts:mcp-catalog"],
"inputs": [
".nvmrc",
"package.json",
"package-lock.json",
"scripts/generate-mcp-tool-catalog.mjs",
@@ -249,7 +243,6 @@
"evidence:check:built": {
"dependsOn": ["artifacts:managed-evidence"],
"inputs": [
".nvmrc",
"package.json",
"package-lock.json",
"scripts/generate-completion-ledger.mjs",