perf(ci): cache built dist artifacts keyed by source content hash
Every shard + the curated-guard job paid ~71s rebuilding 8 packages' dist from scratch. actions/cache now restores dist on exact content-hash match (--print-source-hash; branch-switch stable, pure git-based), with a --seed-artifact-cache step on cache-hit that defeats the mtime trap (restored dist looks older than checkout-time src mtimes). No restore-keys partial fallback: stale dist is a known failure mode here. node_modules is never cached (Windows junction policy). ensure-test-artifacts still runs as the authority and rebuilds anything genuinely missing or changed.
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
computeCombinedSourceHash,
|
||||
detectMissingOrStaleArtifacts,
|
||||
ensureTestArtifacts,
|
||||
isStale,
|
||||
packageSourceInputs,
|
||||
REQUIRED_BUILD_PACKAGES,
|
||||
seedArtifactCache,
|
||||
} from "../ensure-test-artifacts.mjs";
|
||||
|
||||
const ENGINE_ENTRY = REQUIRED_BUILD_PACKAGES.find((pkg) => pkg.name === "@fusion/engine");
|
||||
@@ -498,3 +501,108 @@ function sourceHashFor(gitFn) {
|
||||
gitFn,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CI dist-artifact cache: combined source hash (cache key) + seed mode.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ALL_SOURCE_INPUTS = [
|
||||
...new Set(REQUIRED_BUILD_PACKAGES.flatMap((pkg) => packageSourceInputs(pkg))),
|
||||
];
|
||||
|
||||
test("packageSourceInputs covers every build package (no empty source sets)", () => {
|
||||
for (const pkg of REQUIRED_BUILD_PACKAGES) {
|
||||
assert.ok(
|
||||
packageSourceInputs(pkg).length > 0,
|
||||
`${pkg.name} must contribute at least one source input to the combined hash`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A whole-repo git stub: every source input dir reports a single tracked file
|
||||
* with the given per-path blob sha (defaults to a stable derived value). Lets us
|
||||
* drive the combined hash deterministically without a real work tree.
|
||||
*/
|
||||
function fakeGitForAllSources(blobFor = (filePath) => `blob:${filePath}`) {
|
||||
return (args) => {
|
||||
if (args[0] === "rev-parse") return "true";
|
||||
if (args[0] === "ls-files") {
|
||||
const lines = ALL_SOURCE_INPUTS.map((dir) => {
|
||||
const filePath = `${dir}/index.ts`;
|
||||
return `100644 ${blobFor(filePath)} 0\t${filePath}`;
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
if (args[0] === "status") return ""; // clean
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
test("computeCombinedSourceHash: same tree -> identical hash (deterministic)", () => {
|
||||
const git = fakeGitForAllSources();
|
||||
const a = computeCombinedSourceHash("/repo", git);
|
||||
const b = computeCombinedSourceHash("/repo", git);
|
||||
assert.equal(typeof a, "string");
|
||||
assert.equal(a.length, 64);
|
||||
assert.equal(a, b);
|
||||
});
|
||||
|
||||
test("computeCombinedSourceHash: any source change -> different hash", () => {
|
||||
const base = computeCombinedSourceHash("/repo", fakeGitForAllSources());
|
||||
// Flip the blob sha for engine src only; the combined hash must change.
|
||||
const mutated = computeCombinedSourceHash(
|
||||
"/repo",
|
||||
fakeGitForAllSources((filePath) =>
|
||||
filePath.startsWith("packages/engine/src") ? "blob:CHANGED" : `blob:${filePath}`,
|
||||
),
|
||||
);
|
||||
assert.notEqual(base, mutated);
|
||||
});
|
||||
|
||||
test("computeCombinedSourceHash: returns null outside a git work tree (no unstable key)", () => {
|
||||
const noGit = (args) => (args[0] === "rev-parse" ? "false" : null);
|
||||
assert.equal(computeCombinedSourceHash("/repo", noGit), null);
|
||||
});
|
||||
|
||||
test("seedArtifactCache: records hashes for staleable packages when all artifacts exist", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "fn-dist-seed-"));
|
||||
try {
|
||||
writeFileSync(path.join(root, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n");
|
||||
// All artifacts present.
|
||||
const seeded = seedArtifactCache(root, () => true, fakeGitForAllSources());
|
||||
// recordArtifactBuild only writes entries for packages with source globs
|
||||
// (engine + the 4 plugins); the mtime-immune core/dashboard/plugin-sdk are
|
||||
// returned as "present" but contribute no cache entry.
|
||||
assert.ok(seeded.includes("@fusion/engine"));
|
||||
assert.ok(seeded.includes("@fusion-plugin-examples/hermes-runtime"));
|
||||
|
||||
const cache = JSON.parse(
|
||||
readFileSync(path.join(root, "node_modules", ".cache", "fusion", "artifact-cache.json"), "utf8"),
|
||||
);
|
||||
assert.ok(cache.entries["@fusion/engine"]?.sourceHash);
|
||||
assert.ok(cache.entries["@fusion-plugin-examples/hermes-runtime"]?.sourceHash);
|
||||
// No-glob packages must not get a (meaningless) entry.
|
||||
assert.equal(cache.entries["@fusion/core"], undefined);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("seedArtifactCache: does NOT record a package whose artifacts are missing", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "fn-dist-seed-miss-"));
|
||||
try {
|
||||
writeFileSync(path.join(root, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n");
|
||||
// Engine dist is missing; everything else present.
|
||||
const existsFn = (p) => !p.endsWith("packages/engine/dist/index.js");
|
||||
const seeded = seedArtifactCache(root, existsFn, fakeGitForAllSources());
|
||||
assert.ok(!seeded.includes("@fusion/engine"), "missing-artifact package must not be seeded");
|
||||
|
||||
const cache = JSON.parse(
|
||||
readFileSync(path.join(root, "node_modules", ".cache", "fusion", "artifact-cache.json"), "utf8"),
|
||||
);
|
||||
assert.equal(cache.entries["@fusion/engine"], undefined);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,14 +11,27 @@ import {
|
||||
} from "./lib/content-hash.mjs";
|
||||
|
||||
export const REQUIRED_BUILD_PACKAGES = [
|
||||
{ name: "@fusion/core", requiredArtifacts: ["packages/core/dist/index.js"] },
|
||||
{ name: "@fusion/dashboard", requiredArtifacts: ["packages/dashboard/dist/index.js"] },
|
||||
{
|
||||
name: "@fusion/core",
|
||||
requiredArtifacts: ["packages/core/dist/index.js"],
|
||||
sourceInputs: ["packages/core/src"],
|
||||
},
|
||||
{
|
||||
name: "@fusion/dashboard",
|
||||
requiredArtifacts: ["packages/dashboard/dist/index.js"],
|
||||
// Dashboard's `vite build && tsc` reads both app/ and src/.
|
||||
sourceInputs: ["packages/dashboard/app", "packages/dashboard/src"],
|
||||
},
|
||||
{
|
||||
name: "@fusion/engine",
|
||||
requiredArtifacts: ["packages/engine/dist/index.js"],
|
||||
staleAgainstGlobs: [{ sourcePath: "packages/engine/src" }],
|
||||
},
|
||||
{ name: "@fusion/plugin-sdk", requiredArtifacts: ["packages/plugin-sdk/dist/index.js"] },
|
||||
{
|
||||
name: "@fusion/plugin-sdk",
|
||||
requiredArtifacts: ["packages/plugin-sdk/dist/index.js"],
|
||||
sourceInputs: ["packages/plugin-sdk/src"],
|
||||
},
|
||||
{
|
||||
name: "@fusion-plugin-examples/dependency-graph",
|
||||
requiredArtifacts: [
|
||||
@@ -75,6 +88,53 @@ export const REQUIRED_BUILD_PACKAGES = [
|
||||
|
||||
const ARTIFACT_CACHE_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Repo-relative source input paths for a build package. Prefers the explicit
|
||||
* `sourceInputs` list (covers packages with no mtime-staleness globs, e.g.
|
||||
* @fusion/core), and falls back to the `staleAgainstGlobs` source paths so the
|
||||
* engine + plugin entries keep a single source of truth.
|
||||
*
|
||||
* @param {object} pkgEntry
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function packageSourceInputs(pkgEntry) {
|
||||
if (Array.isArray(pkgEntry?.sourceInputs) && pkgEntry.sourceInputs.length > 0) {
|
||||
return [...pkgEntry.sourceInputs];
|
||||
}
|
||||
if (pkgEntry?.staleAgainstGlobs?.length) {
|
||||
return pkgEntry.staleAgainstGlobs.map((glob) => glob.sourcePath);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable, git-based combined source hash over ALL build packages' source
|
||||
* inputs. Computable BEFORE any build (it only reads git blob SHAs / working
|
||||
* tree bytes, never dist), and branch-switch stable because it defers to git
|
||||
* content rather than file mtimes. Used as the CI dist-cache key.
|
||||
*
|
||||
* Returns null when git is unavailable (no stable key → caller must not cache).
|
||||
*
|
||||
* @param {string} rootDir
|
||||
* @param {(args: string[], cwd: string) => string|null} [gitFn]
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function computeCombinedSourceHash(rootDir = process.cwd(), gitFn = defaultGitRunner) {
|
||||
const probe = gitFn(["rev-parse", "--is-inside-work-tree"], rootDir);
|
||||
if (probe !== "true") return null;
|
||||
// Sorted, de-duplicated union of every package's source inputs so the order in
|
||||
// REQUIRED_BUILD_PACKAGES can't perturb the hash.
|
||||
const inputPaths = [
|
||||
...new Set(REQUIRED_BUILD_PACKAGES.flatMap((pkg) => packageSourceInputs(pkg))),
|
||||
].sort((a, b) => a.localeCompare(b));
|
||||
return computeContentHash({
|
||||
rootDir,
|
||||
inputPaths,
|
||||
versionPrefix: `artifact-combined-v${ARTIFACT_CACHE_VERSION}`,
|
||||
gitFn,
|
||||
});
|
||||
}
|
||||
|
||||
function artifactCachePath(rootDir) {
|
||||
return path.join(fusionCacheDir(rootDir), "artifact-cache.json");
|
||||
}
|
||||
@@ -376,6 +436,48 @@ export function ensureTestArtifacts(
|
||||
return names;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
ensureTestArtifacts();
|
||||
/**
|
||||
* Seed the per-package content-hash cache for every build package whose dist
|
||||
* artifacts are ALL present, recording the current source hash as the "built
|
||||
* baseline". Intended to run right after a CI dist-cache HIT: the restored dist
|
||||
* carries the saved (older) mtime while checkout rewrites src mtimes to "now",
|
||||
* so without a seeded hash-cache `isStale`'s mtime fallback would rebuild
|
||||
* everything and defeat the cache. Seeding adopts the restored content as fresh
|
||||
* so the content-hash short-circuit fires instead.
|
||||
*
|
||||
* Only seeds packages with all artifacts present (never masks a genuinely
|
||||
* missing/partial dist). Returns the list of package names seeded.
|
||||
*
|
||||
* @param {string} rootDir
|
||||
* @param {(p: string) => boolean} [existsFn]
|
||||
* @param {(args: string[], cwd: string) => string|null} [gitFn]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function seedArtifactCache(rootDir = process.cwd(), existsFn = existsSync, gitFn = defaultGitRunner) {
|
||||
const resolvedRootDir = resolveWorkspaceRoot(rootDir);
|
||||
const present = REQUIRED_BUILD_PACKAGES.filter((pkg) =>
|
||||
pkg.requiredArtifacts.every((artifactPath) => existsFn(path.join(resolvedRootDir, artifactPath))),
|
||||
);
|
||||
// recordArtifactBuild itself no-ops packages without source globs (the
|
||||
// mtime-immune @fusion/core/dashboard/plugin-sdk), so only the staleable
|
||||
// packages actually get an entry — exactly the ones that need the override.
|
||||
recordArtifactBuild(present, resolvedRootDir, gitFn);
|
||||
return present.map((pkg) => pkg.name);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const argv = process.argv.slice(2);
|
||||
if (argv.includes("--print-source-hash")) {
|
||||
const hash = computeCombinedSourceHash();
|
||||
if (hash === null) {
|
||||
process.stderr.write("[test-bootstrap] cannot compute source hash: not a git work tree\n");
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(`${hash}\n`);
|
||||
} else if (argv.includes("--seed-artifact-cache")) {
|
||||
const seeded = seedArtifactCache();
|
||||
process.stderr.write(`[test-bootstrap] seeded artifact hash-cache for: ${seeded.join(", ") || "(none)"}\n`);
|
||||
} else {
|
||||
ensureTestArtifacts();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user