perf(ci): gate QA smoke by smoke-visible changes and isolate the prompt snapshot lane (#108202)

* perf(ci): diff-gate QA smoke by CLI import graph and isolate prompt snapshots lane

* fix(ci): run prompt snapshot lane unconditionally

* fix(ci): treat QA lane orchestration files as QA-impacting
This commit is contained in:
Peter Steinberger
2026-07-15 02:51:19 -07:00
committed by GitHub
parent 580938097f
commit 9c8a006d0c
5 changed files with 76 additions and 26 deletions

View File

@@ -540,16 +540,18 @@ jobs:
console.warn(`Changed Node test planning failed; using compact full suite: ${error}`);
}
}
// Targeted (narrow-diff) PRs skip the heavy packaging lanes unless the
// diff touches surfaces those lanes exist to prove: test-only diffs
// cannot change dist bytes, and QA smoke re-proves packaging/scenario
// surfaces that unit shards plus build smokes already cover elsewhere.
// Heavy packaging lanes run only when the diff touches surfaces they
// exist to prove: test-only diffs cannot change dist bytes, and QA
// smoke only sees changes on its scenario surface or inside the
// packaged CLI's import graph. QA gating is diff-based, so it also
// applies when test targeting fell back to the full compact suite.
const changedScopeHasBuildImpact =
changedNodeTestShards === null ||
typeof changedNodeTestPlan.hasBuildArtifactAffectingChange !== "function" ||
changedNodeTestPlan.hasBuildArtifactAffectingChange(changedPaths);
const changedScopeHasQaImpact =
changedNodeTestShards === null ||
changedPaths === null ||
eventName !== "pull_request" ||
typeof changedNodeTestPlan.hasQaSmokeAffectingChange !== "function" ||
changedNodeTestPlan.hasQaSmokeAffectingChange(changedPaths);
const runBuildArtifacts = runNodeFull && changedScopeHasBuildImpact;
@@ -1851,13 +1853,16 @@ jobs:
- check_name: check-additional-boundaries-a
group: boundaries
boundary_shard: 1/4
# This shard runs ~4min on 8 vCPU and regularly owns the PR wall
# clock; extra cores shorten it for similar billed core-minutes.
runner: blacksmith-16vcpu-ubuntu-2404
runner: blacksmith-8vcpu-ubuntu-2404
- check_name: check-additional-boundaries-bcd
group: boundaries
boundary_shard: 2/4,3/4,4/4
runner: blacksmith-8vcpu-ubuntu-2404
# Prompt snapshot regeneration runs real embedded-agent turns (~2min)
# and used to own the boundaries-a wall clock; keep it in its own lane.
- check_name: check-prompt-snapshots
group: prompt-snapshots
runner: blacksmith-8vcpu-ubuntu-2404
- check_name: check-session-accessor-boundary
group: session-accessor-boundary
runner: blacksmith-4vcpu-ubuntu-2404
@@ -1970,6 +1975,12 @@ jobs:
boundaries)
node scripts/run-additional-boundary-checks.mjs
;;
prompt-snapshots)
# No presence fallback: the boundary runner previously invoked
# this unconditionally, and silent success would drop snapshot
# drift coverage.
run_check "prompt:snapshots:check" pnpm prompt:snapshots:check
;;
session-accessor-boundary)
if [ ! -f scripts/check-session-accessor-boundary.mjs ]; then
echo "[skip] session accessor boundary check is not present in this checkout"

View File

@@ -44,19 +44,41 @@ export function hasBuildArtifactAffectingChange(changedPaths) {
return changedPaths.some((changedPath) => !isTestOnlyPath(changedPath));
}
const QA_SMOKE_CRITICAL_RE =
/^(?:extensions\/qa-lab|qa)\/|^scripts\/(?:build-all\.mjs|package-openclaw-for-docker\.mjs)$|^(?:package\.json|pnpm-lock\.yaml|npm-shrinkwrap\.json)$|^ui\//u;
// 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 touches the QA smoke packaging/scenario surface.
* Deliberate product tradeoff: targeted PRs outside this surface skip QA smoke
* and rely on import-selected shards plus build-artifact CLI smokes. Targeting
* only fires for narrow non-SDK-impacting diffs, and QA smoke still runs on
* every node-scoped main push, so a missed regression breaks main visibly
* instead of shipping silently.
* 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) {
return changedPaths.some((changedPath) => QA_SMOKE_CRITICAL_RE.test(changedPath));
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);
}
function createBoundaryShard() {

View File

@@ -15,8 +15,10 @@ const POST_FORCE_KILL_WAIT_MS = 250;
const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
/** Ordered list of supplemental boundary checks used by CI sharding. */
// prompt:snapshots:check is intentionally absent: it regenerates snapshots by
// running real embedded-agent turns (~2min) and owns a dedicated CI lane
// (check-prompt-snapshots) so no boundary shard carries that wall clock.
export const BOUNDARY_CHECKS = [
["prompt:snapshots:check", "pnpm", ["prompt:snapshots:check"]],
["plugin-extension-boundary", "pnpm", ["run", "lint:plugins:no-extension-imports"]],
["lint:docker-e2e", "pnpm", ["run", "lint:docker-e2e"]],
["lint:tmp:no-random-messaging", "pnpm", ["run", "lint:tmp:no-random-messaging"]],

View File

@@ -56,7 +56,20 @@ describe("CI changed Node test plan", () => {
expect(hasBuildArtifactAffectingChange(["src/agents/foo.ts"])).toBe(true);
expect(hasQaSmokeAffectingChange(["extensions/qa-lab/src/ci-smoke-plan.ts"])).toBe(true);
expect(hasQaSmokeAffectingChange(["ui/src/app.ts"])).toBe(true);
expect(hasQaSmokeAffectingChange(["src/infra/retry.ts"])).toBe(false);
// Inside the packaged CLI's import graph -> the smoke scenarios can see it.
expect(hasQaSmokeAffectingChange(["src/infra/retry.ts"])).toBe(true);
// Smoke drives matrix + telegram; other channel plugins are invisible to it.
expect(hasQaSmokeAffectingChange(["extensions/telegram/src/index.ts"])).toBe(true);
expect(hasQaSmokeAffectingChange(["extensions/discord/src/index.ts"])).toBe(false);
expect(hasQaSmokeAffectingChange(["scripts/run-vitest.mjs"])).toBe(false);
expect(hasQaSmokeAffectingChange(["test/scripts/ci-node-test-plan.test.ts"])).toBe(false);
// The QA lane's own orchestration must not be able to skip the lane.
expect(hasQaSmokeAffectingChange([".github/workflows/ci.yml"])).toBe(true);
expect(hasQaSmokeAffectingChange([".github/actions/setup-node-env/action.yml"])).toBe(true);
expect(hasQaSmokeAffectingChange(["scripts/lib/ci-changed-node-test-plan.mjs"])).toBe(true);
expect(hasQaSmokeAffectingChange([".github/workflows/labeler.yml"])).toBe(false);
// Deleted source files cannot be graphed; fail safe to running QA smoke.
expect(hasQaSmokeAffectingChange(["src/infra/definitely-deleted-module.ts"])).toBe(true);
});
it("fails safe to the full plan for broad changes", () => {

View File

@@ -109,12 +109,14 @@ async function waitForChildClose(
}
describe("run-additional-boundary-checks", () => {
it("runs prompt snapshot drift checks in CI", () => {
expect(BOUNDARY_CHECKS[0]).toEqual({
label: "prompt:snapshots:check",
command: "pnpm",
args: ["prompt:snapshots:check"],
});
it("keeps prompt snapshot drift checks in their dedicated CI lane", () => {
// The snapshot check regenerates prompts with real embedded-agent turns
// (~2min); packing it into a boundary shard makes that shard the PR wall
// clock, so it owns the check-prompt-snapshots lane instead.
expect(BOUNDARY_CHECKS.some((check) => check.label === "prompt:snapshots:check")).toBe(false);
const workflow = fs.readFileSync(".github/workflows/ci.yml", "utf8");
expect(workflow).toContain("check_name: check-prompt-snapshots");
expect(workflow).toContain('run_check "prompt:snapshots:check" pnpm prompt:snapshots:check');
});
it("normalizes concurrency input", () => {