mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 14:21:37 +00:00
* fix(ci): address reviewer findings across the perf-lever batch Seven follow-ups from Codex review feedback on the landed CI perf PRs: docker packaging switches to the full build profile (the ciArtifacts step env clobbered its canonical-dts requirement); the boundary sticky restore gates on generative source tree OIDs in both lanes, making restored artifacts valid by construction (kills the clock-skew mtime false-skip and ghost declarations from deleted sources); the node-test worker budget derives from nproc instead of the runner label (labels over-promise under load) and keeps the 2-worker cap when plans can overlap; the deadcode task falls back to serial Knip below 8 cores; the lint-lane i18n verify also runs for any ui-touching diff (keys are extracted from all ui sources, not just ui/src/i18n); the oxlint concurrency diagnostic moves to stderr; and workspace packages join the prompt-snapshot always-run surface. * fix(ci): fingerprint every generative source the boundary artifacts consume Autoreview flagged inputs outside the tree-OID gate; toolchain, config, and script inputs were already covered by the sticky key hash, but the old actions/cache key also fingerprinted src/plugins/types.ts and src/video-generation sources the OID list missed. Add those plus src/channels (the plugin-sdk declaration graph reaches its public types) to the fingerprint in all three sites. * fix(ci): fold runner toolchain identity into the boundary sticky fingerprint Autoreview: the sticky key hashes config/scripts/package.json/lockfile, but a node/pnpm toolchain bump (pinned in the workflow, not the key) could leave the key unchanged and bless artifacts built with the prior toolchain. Record node/pnpm versions alongside the source tree OIDs in all three fingerprint sites so a toolchain-only change forces a cold build.
274 lines
11 KiB
JavaScript
274 lines
11 KiB
JavaScript
import { existsSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { detectChangedLanes } from "../changed-lanes.mjs";
|
|
import {
|
|
buildVitestRunPlans,
|
|
findUnmatchedExplicitTestTargets,
|
|
hasImportGraphImpactOnTargets,
|
|
isTestFileTarget,
|
|
resolveChangedTestTargetPlan,
|
|
} from "../test-projects.test-support.mjs";
|
|
import { createNodeTestShards } from "./ci-node-test-plan.mjs";
|
|
import { buildPluginSdkEntrySources, publicPluginSdkEntrypoints } from "./plugin-sdk-entries.mjs";
|
|
|
|
const DEFAULT_NODE_TEST_RUNNER = "blacksmith-8vcpu-ubuntu-2404";
|
|
const MAX_CHANGED_NODE_TEST_TARGETS = 96;
|
|
// Each target runs in its own child process (isolation contract), so bound the
|
|
// serial tail per job; the shard runner overlaps two children at a time.
|
|
const CHANGED_NODE_TEST_TARGETS_PER_JOB = 12;
|
|
const BOUNDARY_NODE_TEST_CONFIG = "test/vitest/vitest.boundary.config.ts";
|
|
const publicPluginSdkEntrySources = Object.values(
|
|
buildPluginSdkEntrySources(publicPluginSdkEntrypoints),
|
|
);
|
|
|
|
const fullNodeTestShards = createNodeTestShards({ includeReleaseOnlyPluginShards: false });
|
|
const configsRequiringFullSuiteMetadata = new Set(
|
|
fullNodeTestShards
|
|
.filter((shard) => shard.env || shard.shardName.startsWith("core-tooling"))
|
|
.flatMap((shard) => shard.configs),
|
|
);
|
|
const splitNodeTestConfigs = new Set(
|
|
fullNodeTestShards.filter((shard) => shard.includePatterns).flatMap((shard) => shard.configs),
|
|
);
|
|
|
|
function isTestOnlyPath(changedPath) {
|
|
return isTestFileTarget(changedPath) || changedPath.startsWith("test/");
|
|
}
|
|
|
|
// Inputs `build:ci-artifacts` consumes: runtime/plugin/package sources plus
|
|
// the build pipeline itself (mirrors the build-all cache key in ci.yml).
|
|
// Paths outside this set — repo scripts, workflows, qa scenarios, docs mixes —
|
|
// cannot change dist or bundled plugin asset bytes.
|
|
const BUILD_INPUT_RE =
|
|
/^(?:src|extensions|packages)\/|^(?:openclaw\.mjs|package\.json|pnpm-lock\.yaml|npm-shrinkwrap\.json|pnpm-workspace\.yaml)$|^tsconfig[^/]*\.json$|^scripts\/(?:build-[^/]+|write-plugin-sdk-entry-dts\.ts|copy-export-html-templates\.ts)$|^scripts\/lib\/(?:copy-assets\.ts|plugin-sdk-entries\.mjs)$/u;
|
|
|
|
/**
|
|
* True when a changed path can influence built dist/packaging bytes: a
|
|
* non-test build-input source or the build pipeline itself. Diffs entirely
|
|
* outside that set (tests, repo scripts, workflows, qa scenarios) let the
|
|
* manifest skip the build-artifacts lane.
|
|
*/
|
|
export function hasBuildArtifactAffectingChange(changedPaths) {
|
|
return changedPaths.some(
|
|
(changedPath) => BUILD_INPUT_RE.test(changedPath) && !isTestOnlyPath(changedPath),
|
|
);
|
|
}
|
|
|
|
// Surfaces the CI smoke scenarios exercise outside the core runtime import
|
|
// graph: the qa-lab harness and scenario data, the packaged-CLI build inputs,
|
|
// the control UI (playwright scenario), the two channels the smoke profile
|
|
// drives (matrix, telegram), and workspace packages whose package-specifier
|
|
// imports the relative import graph cannot see. The QA lane's own
|
|
// orchestration (this planner, the CI workflow, composite actions) is also
|
|
// QA-impacting: changes to the gate must not be able to skip the gated lane.
|
|
const QA_SMOKE_SURFACE_RE =
|
|
/^(?:extensions\/(?:matrix|qa-lab|telegram)|packages|qa|ui)\/|^scripts\/(?:build-all\.mjs|package-openclaw-for-docker\.mjs)$|^scripts\/lib\/ci-changed-node-test-plan\.mjs$|^\.github\/(?:workflows\/ci\.yml$|actions\/)|^(?:openclaw\.mjs|package\.json|pnpm-lock\.yaml|npm-shrinkwrap\.json|pnpm-workspace\.yaml|tsdown\.config\.ts)$/u;
|
|
// The smoke profile runs the packaged CLI end to end, so its runtime blast
|
|
// radius is exactly the CLI entry's import graph (dynamic imports included).
|
|
const QA_SMOKE_RUNTIME_ENTRY = "src/index.ts";
|
|
|
|
/**
|
|
* True when a changed path can influence the QA smoke scenarios: it touches
|
|
* the smoke surface directly, or the packaged CLI's import graph reaches it.
|
|
* Diffs outside both are invisible to the smoke profile, so the manifest may
|
|
* skip that lane regardless of whether test targeting fired.
|
|
*/
|
|
export function hasQaSmokeAffectingChange(changedPaths, options = {}) {
|
|
const cwd = options.cwd ?? process.cwd();
|
|
if (changedPaths.some((changedPath) => QA_SMOKE_SURFACE_RE.test(changedPath))) {
|
|
return true;
|
|
}
|
|
const sourcePaths = changedPaths.filter(
|
|
(changedPath) => changedPath.startsWith("src/") && !isTestFileTarget(changedPath),
|
|
);
|
|
if (sourcePaths.length === 0) {
|
|
return false;
|
|
}
|
|
// Deleted sources cannot be graphed; fail safe to running the smoke lane.
|
|
if (sourcePaths.some((changedPath) => !existsSync(path.join(cwd, changedPath)))) {
|
|
return true;
|
|
}
|
|
return hasImportGraphImpactOnTargets(sourcePaths, [QA_SMOKE_RUNTIME_ENTRY], cwd);
|
|
}
|
|
|
|
// Surfaces the prompt-snapshot check exercises outside its generator's
|
|
// relative import graph: the snapshot fixtures and generator scripts, the
|
|
// codex extension (its test API loads through a dynamic bundled-plugin module
|
|
// id the graph walk cannot see), and the gate's own orchestration — changes
|
|
// to the gate must not be able to skip the gated lane.
|
|
const PROMPT_SNAPSHOT_SURFACE_RE =
|
|
/^(?:test\/(?:helpers\/agents|fixtures\/agents\/prompt-snapshots)|extensions\/codex|packages)\/|^scripts\/(?:generate-prompt-snapshots\.ts|prompt-snapshot-files\.[cm]?[jt]s)$|^scripts\/lib\/ci-changed-node-test-plan\.mjs$|^\.github\/(?:workflows\/ci\.yml$|actions\/)|^(?:package\.json|pnpm-lock\.yaml|npm-shrinkwrap\.json|pnpm-workspace\.yaml)$/u;
|
|
// The generator renders real prompt-layer stacks, so its runtime blast radius
|
|
// is the snapshot helper's import graph (auto-reply prompts, channel typing,
|
|
// plugin-sdk agent harness, codex catalog fixtures).
|
|
const PROMPT_SNAPSHOT_ENTRY = "test/helpers/agents/happy-path-prompt-snapshots.ts";
|
|
|
|
/**
|
|
* True when a changed path can influence generated prompt snapshots: it
|
|
* touches the snapshot surface directly, or the generator's import graph
|
|
* reaches it. Diffs outside both cannot change generator output, so the
|
|
* manifest may skip the check lane.
|
|
*/
|
|
export function hasPromptSnapshotAffectingChange(changedPaths, options = {}) {
|
|
const cwd = options.cwd ?? process.cwd();
|
|
if (changedPaths.some((changedPath) => PROMPT_SNAPSHOT_SURFACE_RE.test(changedPath))) {
|
|
return true;
|
|
}
|
|
const sourcePaths = changedPaths.filter(
|
|
(changedPath) => changedPath.startsWith("src/") && !isTestFileTarget(changedPath),
|
|
);
|
|
if (sourcePaths.length === 0) {
|
|
return false;
|
|
}
|
|
// Deleted sources cannot be graphed; fail safe to running the check.
|
|
if (sourcePaths.some((changedPath) => !existsSync(path.join(cwd, changedPath)))) {
|
|
return true;
|
|
}
|
|
return hasImportGraphImpactOnTargets(sourcePaths, [PROMPT_SNAPSHOT_ENTRY], cwd);
|
|
}
|
|
|
|
function createBoundaryShard() {
|
|
// Boundary tests scan the source tree (including test files) and build
|
|
// their own fixtures; they do not consume the built dist artifact. When the
|
|
// build-artifacts lane is skipped, this shard keeps that coverage.
|
|
return {
|
|
checkName: "checks-node-changed-boundary",
|
|
configs: [BOUNDARY_NODE_TEST_CONFIG],
|
|
requiresDist: false,
|
|
runner: DEFAULT_NODE_TEST_RUNNER,
|
|
shardName: "changed-boundary",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Builds bounded PR jobs from precise changed-test targets.
|
|
* Null means the caller must fail safe to the compact full-suite plan.
|
|
*/
|
|
export function createChangedNodeTestShards(changedPaths, options = {}) {
|
|
const cwd = options.cwd ?? process.cwd();
|
|
if (!Array.isArray(changedPaths) || changedPaths.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const livePaths = [];
|
|
const deletedPaths = [];
|
|
for (const changedPath of changedPaths) {
|
|
(existsSync(path.join(cwd, changedPath)) ? livePaths : deletedPaths).push(changedPath);
|
|
}
|
|
// Deleted test files cannot regress runtime behavior, so they never block
|
|
// targeting. Deleted source files cannot be import-graphed from the merged
|
|
// tree and no live-path heuristic proves their consumers are covered, so
|
|
// any source deletion keeps the full-suite plan.
|
|
if (deletedPaths.some((deletedPath) => !isTestFileTarget(deletedPath))) {
|
|
return null;
|
|
}
|
|
|
|
// Workspace package consumers often use package specifiers, which the
|
|
// relative import graph cannot connect back to the changed package source.
|
|
if (changedPaths.some((changedPath) => changedPath.startsWith("packages/"))) {
|
|
return null;
|
|
}
|
|
|
|
// Package-specifier consumers are invisible to the relative import graph.
|
|
// Fail safe when a core change reaches a public SDK entrypoint indirectly.
|
|
if (
|
|
detectChangedLanes(changedPaths).extensionImpactFromCore ||
|
|
(livePaths.some((changedPath) => changedPath.startsWith("src/")) &&
|
|
hasImportGraphImpactOnTargets(livePaths, publicPluginSdkEntrySources, cwd))
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const resolveTargetPlan = (paths) =>
|
|
resolveChangedTestTargetPlan(paths, {
|
|
broad: true,
|
|
combineSiblingWithImportGraph: true,
|
|
cwd,
|
|
forceFullImportGraph: true,
|
|
includeExtensionImpact: false,
|
|
});
|
|
const plan =
|
|
livePaths.length > 0 ? resolveTargetPlan(livePaths) : { mode: "targets", targets: [] };
|
|
// Aggregate resolution must not let one precise path hide another path that
|
|
// contributes no tests. Partial plans silently drop coverage.
|
|
if (
|
|
livePaths.some((changedPath) => {
|
|
const changedPathPlan = resolveTargetPlan([changedPath]);
|
|
return changedPathPlan.mode !== "targets" || changedPathPlan.targets.length === 0;
|
|
})
|
|
) {
|
|
return null;
|
|
}
|
|
if (plan.mode !== "targets") {
|
|
return null;
|
|
}
|
|
const targets = [...new Set(plan.targets)];
|
|
if (
|
|
targets.length > MAX_CHANGED_NODE_TEST_TARGETS ||
|
|
targets.some(
|
|
(target) =>
|
|
/^test\/vitest\/vitest\.full-.*\.config\.ts$/u.test(target) ||
|
|
splitNodeTestConfigs.has(target),
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
targets.some(
|
|
(target) =>
|
|
!isTestFileTarget(target) || findUnmatchedExplicitTestTargets([target], cwd).length > 0,
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const targetPlans = targets.map((target) => ({
|
|
plans: buildVitestRunPlans([target], cwd),
|
|
target,
|
|
}));
|
|
if (
|
|
targetPlans.some(
|
|
({ plans }) => plans.length === 0 || plans.some((targetPlan) => !targetPlan.includePatterns),
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
// Preserve special shard setup (for example Go and TUI PTY coverage) by using
|
|
// the compact plan until targeted jobs can carry per-config prerequisites.
|
|
if (
|
|
targetPlans.some(({ plans }) =>
|
|
plans.some(({ config }) => configsRequiringFullSuiteMetadata.has(config)),
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
// Boundary-config targets run as regular nondist targets: the boundary
|
|
// suite scans the checked-out tree and never consumes the built dist.
|
|
const orderedTargets = targetPlans.map(({ target }) => target);
|
|
const targetChunks = [];
|
|
for (
|
|
let offset = 0;
|
|
offset < orderedTargets.length;
|
|
offset += CHANGED_NODE_TEST_TARGETS_PER_JOB
|
|
) {
|
|
targetChunks.push(orderedTargets.slice(offset, offset + CHANGED_NODE_TEST_TARGETS_PER_JOB));
|
|
}
|
|
const shards = [
|
|
...targetChunks.map((chunk, index) => {
|
|
const suffix = targetChunks.length === 1 ? "" : `-${index + 1}`;
|
|
return {
|
|
checkName: `checks-node-changed${suffix}`,
|
|
configs: [],
|
|
requiresDist: false,
|
|
runner: DEFAULT_NODE_TEST_RUNNER,
|
|
shardName: `changed${suffix}`,
|
|
targets: chunk,
|
|
};
|
|
}),
|
|
...(hasBuildArtifactAffectingChange(changedPaths) ? [] : [createBoundaryShard()]),
|
|
];
|
|
return shards.length > 0 ? shards : null;
|
|
}
|