perf(ci): persist Node 22 build cache artifacts (#109780)

This commit is contained in:
Peter Steinberger
2026-07-17 01:04:57 -07:00
committed by GitHub
parent 6ac15970a7
commit a69af59741
3 changed files with 128 additions and 2 deletions
+84 -1
View File
@@ -6,6 +6,7 @@ on:
workflow_dispatch:
permissions:
actions: read
contents: read
jobs:
@@ -22,7 +23,77 @@ jobs:
with:
node-version: "22.22.3"
install-bun: "false"
build-all-cache-scope: full
- name: Resolve trusted declaration cache artifact
id: declaration_cache
env:
ARTIFACT_NAME: build-all-cache-v1-Linux-X64-node-22.22.3
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
if ! runs_json="$(
gh api \
"repos/${GITHUB_REPOSITORY}/actions/workflows/node22-compat.yml/runs?branch=main&status=success&per_page=5" \
2>/dev/null
)"; then
echo "::warning::Unable to list trusted Node 22 runs; continuing cold."
exit 0
fi
while IFS=$'\t' read -r run_id source_sha; do
[[ "$run_id" =~ ^[1-9][0-9]*$ && "$source_sha" =~ ^[0-9a-f]{40}$ ]] || continue
if ! artifacts_json="$(
gh api \
"repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/artifacts?per_page=10" \
2>/dev/null
)"; then
continue
fi
artifact_id="$(
jq -r \
--arg name "$ARTIFACT_NAME" \
--arg run_id "$run_id" \
'first(.artifacts[] | select(
.expired == false and
.name == $name and
(.workflow_run.id | tostring) == $run_id
) | .id) // empty' \
<<<"$artifacts_json"
)"
if [[ ! "$artifact_id" =~ ^[1-9][0-9]*$ ]]; then
continue
fi
echo "artifact_id=$artifact_id" >> "$GITHUB_OUTPUT"
echo "run_id=$run_id" >> "$GITHUB_OUTPUT"
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
exit 0
done < <(
jq -r '
.workflow_runs[] |
select(
.status == "completed" and
.conclusion == "success" and
.head_branch == "main" and
(.path | split("@")[0]) == ".github/workflows/node22-compat.yml" and
(.event == "schedule" or .event == "workflow_dispatch")
) |
[.id, .head_sha] |
@tsv
' <<<"$runs_json"
)
- name: Restore trusted declaration cache artifact
if: steps.declaration_cache.outputs.artifact_id != ''
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ steps.declaration_cache.outputs.artifact_id }}
github-token: ${{ github.token }}
merge-multiple: true
path: .artifacts/build-all-cache
repository: ${{ github.repository }}
run-id: ${{ steps.declaration_cache.outputs.run_id }}
- name: Configure Node test resources
run: echo "OPENCLAW_VITEST_MAX_WORKERS=2" >> "$GITHUB_ENV"
@@ -36,3 +107,15 @@ jobs:
node openclaw.mjs --help
node openclaw.mjs status --json --timeout 1
pnpm test:build:singleton
- name: Publish trusted declaration cache artifact
if: success() && github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
compression-level: 1
if-no-files-found: error
include-hidden-files: true
name: build-all-cache-v1-${{ runner.os }}-${{ runner.arch }}-node-22.22.3
overwrite: true
path: .artifacts/build-all-cache
retention-days: 14
+1 -1
View File
@@ -117,7 +117,7 @@ The slowest Node test families are split or balanced so each job stays small wit
- Linux Node shard jobs persist Vitest's experimental filesystem module cache. Trusted Blacksmith jobs use PR-scoped writable overlays seeded from the protected snapshot only when their transform-input generations match; GitHub-hosted and fork jobs use an `actions/cache` fallback with coarse restore prefixes. The planner marks the broad `core-unit-fast` graph as the single writer without coupling cache ownership to matrix order, while every other job restores a private read-only clone. Concurrent Vitest workers retain separate live directories. A transform-input fingerprint clears incompatible lockfile, package, tsconfig, and Vitest-config generations inside stable sticky keys. Only the writer scans and prunes the cache to 75% after it exceeds 2 GiB. A non-cancelling daily or default-branch repository-dispatch warmer prevents rapid `main` pushes from starving the protected seed, and closed PR cache archives are deleted.
- Node shard and build-artifact jobs also restore Node's portable on-disk compile cache. Independent `test` and `build` namespaces prevent their writers from replacing each other's snapshots: the scheduled test warmer owns the protected test seed, while `build-artifacts` publishes the protected build seed only from trusted `main` pushes. PR jobs read protected snapshots without publishing feature-branch bytecode; fallback archives remain PR-scoped. This reuses V8 bytecode for Node-loaded orchestration, build tooling, and external dependencies across different checkout paths, including when only part of the source graph changes. Vitest child processes disable an inherited compile cache because coverage can be enabled inside dynamic configs and V8 coverage can lose source-position precision when scripts are deserialized from bytecode.
- The build-artifact job also persists content-fingerprinted `build-all` step outputs. CI's self-built plugin SDK declarations hash the complete repository-owned TypeScript/JSON source graph, exclude installed and generated directories, and restore both flat declarations and package bridges after `tsdown` clears `dist`. Documentation, workflow, plugin, and other changes outside that graph can reuse the declaration snapshot; source changes rebuild it before the export gate runs.
- Full declaration builds split `tsdown` into AI, workspace-package, and unified groups. Each group caches declarations only, then still rebuilds runtime JavaScript before restoring those declarations. Core or plugin changes therefore invalidate only the large unified graph, while workspace-package changes conservatively invalidate every dependent declaration group. Public full builds use an immutable Actions cache; coarse restore keys seed partial changes, per-group content fingerprints reject stale data, and GitHub's cache quota evicts old generations. Private-QA declarations are never persisted in Actions caches because cache namespaces are not confidentiality boundaries.
- Full declaration builds split `tsdown` into AI, workspace-package, and unified groups. Each group caches declarations only, then still rebuilds runtime JavaScript before restoring those declarations. Core or plugin changes therefore invalidate only the large unified graph, while workspace-package changes conservatively invalidate every dependent declaration group. Public full builds generally use an immutable Actions cache; coarse restore keys seed partial changes, per-group content fingerprints reject stale data, and GitHub's cache quota evicts old generations. The weekly Node 22 lane instead publishes a 14-day artifact after successful `main` runs and restores only artifacts whose immutable producer identity resolves to that workflow on `main`, avoiding quota churn without allowing PR code to write a shared cache. Private-QA declarations are never persisted in Actions caches because cache namespaces are not confidentiality boundaries.
- `check-additional-*` stripes the supplemental boundary guard list (`scripts/run-additional-boundary-checks.mjs`) into one prompt-heavy shard (`check-additional-boundaries-a`, which includes the Codex prompt snapshot drift check) and one combined shard for the remaining stripes (`check-additional-boundaries-bcd`), each running independent guards concurrently and printing per-check timings. Package-boundary compile/canary work stays together, and runtime topology architecture runs separately from the gateway watch coverage embedded in `build-artifacts`.
- Gateway watch, channel tests, and the core support-boundary shard run concurrently inside `build-artifacts` after `dist/` and `dist-runtime/` are already built.
+43
View File
@@ -2125,6 +2125,49 @@ describe("ci workflow guards", () => {
}
});
it("persists Node 22 declarations through trusted bounded artifacts", () => {
const workflow = parse(readFileSync(".github/workflows/node22-compat.yml", "utf8"));
const steps = workflow.jobs.compat.steps as WorkflowStep[];
const setupStep = steps.find((step) => step.name === "Setup Node environment");
const resolveStep = steps.find(
(step) => step.name === "Resolve trusted declaration cache artifact",
);
const downloadStep = steps.find(
(step) => step.name === "Restore trusted declaration cache artifact",
);
const uploadStep = steps.find(
(step) => step.name === "Publish trusted declaration cache artifact",
);
expect(workflow.permissions).toMatchObject({ actions: "read", contents: "read" });
expect(setupStep?.with).not.toHaveProperty("build-all-cache-scope");
expect(resolveStep?.run).toContain('.head_branch == "main"');
expect(resolveStep?.run).toContain('(.path | split("@")[0])');
expect(resolveStep?.run).toContain('.conclusion == "success"');
expect(resolveStep?.run).toContain("status=success&per_page=5");
expect(resolveStep?.run).toContain("artifacts?per_page=10");
expect(resolveStep?.run).not.toContain("--paginate");
expect(downloadStep).toMatchObject({
if: "steps.declaration_cache.outputs.artifact_id != ''",
uses: DOWNLOAD_ARTIFACT_V8,
with: {
path: ".artifacts/build-all-cache",
repository: "${{ github.repository }}",
},
});
expect(uploadStep).toMatchObject({
if: "success() && github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main'",
uses: UPLOAD_ARTIFACT_V7,
with: {
"if-no-files-found": "error",
"include-hidden-files": true,
overwrite: true,
path: ".artifacts/build-all-cache",
"retention-days": 14,
},
});
});
it("restores importer-local node_modules from sticky snapshots", () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-sticky-importers-"));
try {