ci: move release tooling process tours out of PR checks (#156123)

* test: split FRV CLI tours from fast recovery contracts

* ci: defer installer and FRV process tours to release validation
This commit is contained in:
Peter Steinberger
2026-09-22 20:26:06 -07:00
committed by GitHub
parent d57d083f86
commit f8b7dbaa46
9 changed files with 4019 additions and 3969 deletions
+2 -1
View File
@@ -21,7 +21,8 @@ The slowest Node test families are split or balanced so each job stays small wit
- A changed tooling test or owner selects the full canonical tooling family, including changes that the precise resolver could narrow to individual files. The tooling configs retain their file-parallelism policy, worker pins, runtime prerequisites, logical backend routing, and capacity promotion. Ordinary tooling prices admitted file workers while retaining the longest-file floor; Docker helper fixtures keep their separate serial config. They do not become generic changed-target jobs. The existing dist boundary and TUI descriptors retain their declared prerequisites.
- Precisely resolved metadata-bearing files, including the four embedded-agent owners, select their canonical groups after config expansion and packing. They retain the original job's runner, worker/admission policy, preparation and conservative timing floor while removing unrelated groups and narrowing single-config groups to their selected files. The known Docker/isolated tooling pair retains both complete configs once, with its canonical timing identity. Subset timing identities cannot overwrite complete-parent observations. Ambiguous config ownership, native Vitest shard arguments and unproven dist/TUI selections still fall back. Test-only nondist selections retain the separate full boundary gate.
- When canonical pull requests fall back to compact planning, directly changed, existing tests owned by the release-only plugin shard remain as exact-file selections in the existing compact jobs. Canonical Vitest routing keeps unit-fast, contract, bundled, and E2E tests with their own suites; source files, directories, deleted tests, and live tests do not widen plugin coverage. The broad `agentic-plugins` sweep remains release-only, and push and manual CI plans are unchanged.
- The planner's `RELEASE_ONLY_TOOLING_SHARDS` set and matching maintainer leaves in mixed fast configs defer the complete maintainer-tooling family on product-only PRs, in both precise and compact fallback plans. The tooling Vitest configs own the ordinary inventory and isolated/Docker catalogs. Maintainer leaves selected by fast configs retain their ordinary, isolated, or fake-timer owner and process pins; filtering mixed groups preserves product neighbors and gives the subsets separate timing identities. Dedicated product E2E and live tests, including the five `test/scripts/*.e2e.test.ts` gates, stay outside this tier. PRs touching a tooling test or owner run the full family: `scripts/**`, `src/scripts/**`, `test/**`, `.github/**`, `config/**`, root package/pnpm inputs, tooling configs, and other inputs classified as tooling by the shared changed-path owner in `scripts/test-projects.test-support.mts`. That owner also covers Docker, agent/Crabbox tooling, app scripts/Fastlane, and extension scripts/package inputs. Directly changed release-only tests therefore retain coverage on their own PRs. Every CI `workflow_dispatch` includes the family, including Full Release Validation's `normal_ci` child against the frozen candidate. Its `Run Node test shard` step runs these unchanged tests before regular publication admission; an independent duplicate release test is not required. The existing approved preflight-only beta publication exception remains unchanged. Plugin Prerelease separately owns `agentic-plugins`; CI's plugin exclusion does not control the tooling tier. Fork repositories keep full tooling because they do not use canonical PR targeting. Product tests retain their existing tiers except for the explicit runtime proof inventory below.
- The planner's `RELEASE_ONLY_TOOLING_SHARDS` set and matching maintainer leaves in mixed fast configs defer the complete maintainer-tooling family on product-only PRs, in both precise and compact fallback plans. The tooling Vitest configs own the ordinary inventory and isolated/Docker catalogs. Maintainer leaves selected by fast configs retain their ordinary, isolated, or fake-timer owner and process pins; filtering mixed groups preserves product neighbors and gives the subsets separate timing identities. Dedicated product E2E and live tests, including the five `test/scripts/*.e2e.test.ts` gates, stay outside this tier. PRs touching a tooling test or owner run the family, except the explicit process proofs below: `scripts/**`, `src/scripts/**`, `test/**`, `.github/**`, `config/**`, root package/pnpm inputs, tooling configs, and other inputs classified as tooling by the shared changed-path owner in `scripts/test-projects.test-support.mts`. That owner also covers Docker, agent/Crabbox tooling, app scripts/Fastlane, and extension scripts/package inputs. Directly changed tooling tests retain coverage on their own PRs except for the explicit process proofs. Every CI `workflow_dispatch` includes the family, including Full Release Validation's `normal_ci` child against the frozen candidate. Its `Run Node test shard` step runs these unchanged tests before regular publication admission; an independent duplicate release test is not required. The existing approved preflight-only beta publication exception remains unchanged. Plugin Prerelease separately owns `agentic-plugins`; CI's plugin exclusion does not control the tooling tier. Fork repositories keep full tooling because they do not use canonical PR targeting. Product tests retain their existing tiers except for the explicit runtime proof inventory below.
- The explicit `CI_PROOF_TEST_FILES` inventory keeps `test/scripts/frv.release.test.ts` and `test/scripts/install-ps1.release.test.ts` out of PR plans, including directly edited tests and compact fallback. Main already omits their tooling owner; manual CI and Full Release Validation retain both complete process tours in the canonical tooling config. FRV keeps its in-process continuation contracts in `frv.test.ts`; the installer keeps its source guards in `install-ps1.test.ts`. No cases or assertions are removed.
- The explicit `RELEASE_ONLY_RUNTIME_TEST_FILES` inventory defers expensive native runtime proof files from canonical automatic PR and push plans. It includes the Doctor startup/rollback CLI and plugin-install process tours, the heap-limited session import, the native ACP execution and Git exact-state race tours, and the native lifecycle, launchd recovery, and systemd recovery handoff matrices. The existing release inventory remains: `src/flows/doctor-health.test.ts`, `src/infra/update-managed-service-handoff-foreground.test.ts`, `src/node-host/node-worker-supervisor.recovery.test.ts`, `src/state/openclaw-database-preflight.lifecycle.test.ts`, and all eight `src/config/state-startup-corpus*.test.ts` wrappers. The startup-corpus paths owner supplies the complete family in its existing partition order. The files keep their canonical configs, process isolation, runtime prerequisites, worker limits, cases, and assertions. Parallel command groups preserve the reduced coverage timing identity when applying worker policy, so automatic CI samples cannot overwrite full release estimates. Directly edited existing test files run on their PRs, including the former main-only Doctor refusal proof, compact fallback, and exact-head release-gate substitutes; source/helper changes, missing or deleted files, directories, and broad fallback do not admit the remaining release matrix. Manual CI, Full Release Validation's `normal_ci` child, local full plans, and noncanonical repositories retain the complete inventory. The release tier also keeps the separate `published-upgrade-survivor` Docker cell with the published baseline, `legacy-operator-state` scenario, and `auto-auth` restart mode; the synthetic foreground-handoff cases do not substitute for that published-driver × candidate proof.
- `config-startup-corpus.test.ts` remains in automatic CI. The manifest resolves one startup-corpus inventory for both Node coverage receipts and the baseline-ratchets fallback: the regular config corpus plus directly edited state wrappers on automatic PRs, and the complete family on manual/release validation. A receipt covers that selected inventory on the exact checkout/workflow revision, not the deferred full matrix. The fallback still builds `qaRuntime` once and runs the selected files with at most four available workers. Older targets without selection capability keep their full split-file or legacy sharded fallback. Canonical main's full Node plan owns its regular corpus once.
- Canonical `main` pushes use a Blacksmith integration compact with nondist Node jobs plus the dist boundary descriptor. Former multi-config walls (CLI plus CLI-process, isolated plus fake-timers unit fast, and the logging/process/runtime-config trio) are split into per-config shards so no single group floors a lane. Real Node+TSX command tests belong to the isolated CLI-process catalog, so ordinary CLI tests do not prepare a runtime. The process catalog splits by complete file costs, with split sizing bounded below by its complete file costs and runtime prerequisite so an older aggregate timing cannot hide newly owned work. File packing also includes the prerequisite; runtime-consuming CLI children may share one preparation in the same serial job when their complete combined estimate fits the existing 150-second budget. Each child retains its selected files, isolated process and two-worker limit; deliberately separated fixed stripe families remain apart. The gateway process file stays alone because its cold proof already takes 200 seconds. CLI children retain the 150-second sizing target and their two-worker limit. Ordinary hybrid bins containing only non-build CLI children may combine up to 250 predicted seconds, with each original child still admitted separately below 150 seconds; the hosted runtime prerequisite itself has a 160-second floor. They omit the low-signal-per-push tooling and TUI PTY groups while retaining product-runtime groups outside the explicit release tier, including three file-weighted stripes apiece for unit-src, Control UI, and gateway-core. Blacksmith serial admission stays at 200 seconds for the large class and 276 seconds for the small class. Ordinary groups that can share two process slots admit 360 predicted aggregate seconds; a group already above its serial cap stays alone. Manual dispatches and Full Release Validation retain the full named per-shard matrix. No scheduled workflow currently runs that full Node suite; this is a known coverage-timing gap, not coverage supplied by this compact plan.
+4 -1
View File
@@ -1,6 +1,7 @@
import { stateStartupCorpusTestFiles } from "../../test/vitest/vitest.startup-corpus-paths.mjs";
// Complete process/lifecycle proofs run on main and release verification.
// Complete process/lifecycle proofs stay outside PR CI. Main retains runtime
// owners; manual/release validation also retains the tooling owner.
// Keep this explicit: E2E-named package and browser boundary tests stay on PRs.
export const CI_PROOF_TEST_FILES = [
"extensions/browser/src/browser/extension-install.native-host.e2e.test.ts",
@@ -8,6 +9,8 @@ export const CI_PROOF_TEST_FILES = [
"test/e2e/qa-lab/plugins/discord-show-widget-contextual-presenter.e2e.test.ts",
"test/e2e/qa-lab/runtime/sessions-send-visible-child.product-proof.e2e.test.ts",
"test/scripts/doctor-config-preflight-plugin-index.built-cli.e2e.test.ts",
"test/scripts/frv.release.test.ts",
"test/scripts/install-ps1.release.test.ts",
"test/scripts/sqlite-sessions-transcripts-flip-proof.built-cli.e2e.test.ts",
"test/scripts/sqlite-sessions-transcripts-flip-proof.e2e.test.ts",
] as const;
+10 -1
View File
@@ -3846,7 +3846,16 @@ describe("scripts/lib/ci-node-test-plan.mts", () => {
toolingGroups.every((group) => group.configs[0] === "test/vitest/vitest.tooling.config.ts"),
).toBe(true);
expect(new Set(toolingFiles).size).toBe(toolingFiles.length);
expect(toolingFiles.toSorted((a, b) => a.localeCompare(b))).toEqual(listAllToolingTestFiles());
const allToolingFiles = listAllToolingTestFiles();
expect(toolingFiles.toSorted((a, b) => a.localeCompare(b))).toEqual(
allToolingFiles.filter((file) => !isCiProofTestFile(file)),
);
expect(
base
.filter((shard) => shard.configs[0] === "test/vitest/vitest.tooling.config.ts")
.flatMap((shard) => shard.includePatterns ?? [])
.toSorted((a, b) => a.localeCompare(b)),
).toEqual(allToolingFiles);
}
it.each(plannerHosts)(
"preserves coverage and execution policies with committed compact measurements ($label)",
File diff suppressed because it is too large Load Diff
+208
View File
@@ -0,0 +1,208 @@
import { buildFullReleaseCandidateRequest } from "../../scripts/full-release-candidate-contract.mjs";
import {
buildReleaseExecutionPlan,
buildReleaseExecutionPlanArtifact,
releaseChildSpec,
releaseExecutionPlanSha256,
} from "../../scripts/full-release-validation-policy.mjs";
export const SHA = "a".repeat(40);
export const TARGET_SHA = "b".repeat(40);
export const SOURCE_REF = `release-ci/${SHA.slice(0, 12)}-77`;
export const REPOSITORY = "openclaw/openclaw";
export function job(name: string, conclusion = "success") {
return {
completed_at: "2026-08-22T00:01:00Z",
conclusion,
html_url: `https://example.invalid/jobs/${name}`,
name,
started_at: "2026-08-22T00:00:00Z",
status: "completed",
};
}
export function child(key: string, runId: string) {
const spec = releaseChildSpec(key);
return {
displayTitle: `${spec.displayName} full-release-validation-77-1${spec.suffix}`,
key,
required: true,
runAttempt: 1,
runId,
selected: true,
sourceParentAttempt: 1,
url: `https://github.com/${REPOSITORY}/actions/runs/${runId}`,
workflow: spec.workflow,
workflowRef: SOURCE_REF,
workflowSha: SHA,
};
}
export function withoutChildRunIdentity(entry: ReturnType<typeof child>) {
const missing = structuredClone(entry);
Reflect.set(missing, "runAttempt", null);
Reflect.set(missing, "runId", "");
Reflect.set(missing, "url", "");
return missing;
}
export function requiredChildren() {
return [
child("normalCi", "101"),
child("pluginPrerelease", "202"),
child("releaseChecks", "303"),
child("productPerformance", "404"),
];
}
export function plan(children = requiredChildren()) {
return {
attemptEvidenceVersion: 2,
children,
parentRunAttempt: 1,
parentRunId: "77",
releaseProfile: "beta",
rerunGroup: "all",
targetSha: TARGET_SHA,
trustedWorkflow: { fullRef: "refs/heads/main", ref: "main", sha: SHA },
workflowRef: SOURCE_REF,
workflowSha: SHA,
};
}
export function executionPlanArtifact({
children = requiredChildren(),
evidenceReuse = { requested: false },
}: {
children?: ReturnType<typeof requiredChildren>;
evidenceReuse?: Record<string, unknown>;
} = {}) {
const built = buildReleaseExecutionPlan({
children: Object.fromEntries(
children.map((entry) => [
entry.key,
{
result: "success",
runAttempt: entry.runAttempt,
runId: entry.runId,
url: entry.url,
},
]),
),
dockerPreflightResult: "success",
evidenceReuse: evidenceReuse.requested === true,
parentRunAttempt: 1,
parentRunId: "77",
candidateBindingResult: "success",
rerunGroup: "all",
resolveTargetResult: "success",
workflowRef: SOURCE_REF,
workflowSha: SHA,
});
const candidateRequest = buildFullReleaseCandidateRequest({
repository: REPOSITORY,
targetSha: TARGET_SHA,
toolingSha: SHA,
releaseProfile: "beta",
releaseSoak: false,
upgradeSurvivorBaseline: "openclaw@latest",
upgradeSurvivorBaselines: "",
upgradeSurvivorScenarios: "",
allowFrozenTargetScenarioOmissions: false,
allowUnreleasedChangelog: false,
packagePublished: false,
sharedImagePolicy: "no-push-artifact",
});
const selectedKeys = new Set(children.map((entry) => entry.key));
return buildReleaseExecutionPlanArtifact({
attemptEvidenceVersion: 2,
candidate: null,
children: built.children.map((entry) =>
selectedKeys.has(entry.key)
? entry
: {
...entry,
required: false,
result: "skipped",
runAttempt: null,
runId: "",
selected: false,
url: "",
},
),
evidenceReuse,
expected: {
candidateRequest,
parentRunAttempt: 1,
parentRunId: "77",
repository: REPOSITORY,
targetSha: TARGET_SHA,
workflowRef: SOURCE_REF,
workflowSha: SHA,
},
gates: built.gates,
releaseProfile: "beta",
rerunGroup: "all",
trustedWorkflow: { fullRef: "refs/heads/main", ref: "main", sha: SHA },
});
}
export function historicalExecutionPlanArtifact() {
const artifact = structuredClone(executionPlanArtifact());
delete artifact.attemptEvidenceVersion;
delete artifact.candidate;
delete artifact.candidateRequest;
delete artifact.repository;
for (const entry of artifact.children) {
delete entry.sourceParentAttempt;
}
artifact.sha256 = releaseExecutionPlanSha256(artifact);
return artifact;
}
export function runFor(
entry: ReturnType<typeof child>,
attempt: number,
conclusion: string | null,
status = conclusion === null ? "in_progress" : "completed",
) {
return {
actor: { login: "github-actions[bot]" },
conclusion,
display_title: entry.displayTitle,
event: "workflow_dispatch",
head_branch: entry.workflowRef,
head_sha: entry.workflowSha,
html_url: entry.url,
id: Number(entry.runId),
path: `.github/workflows/${entry.workflow}`,
repository: { full_name: REPOSITORY },
run_attempt: attempt,
status,
triggering_actor: {
login: attempt === entry.runAttempt ? "github-actions[bot]" : "release-operator",
},
};
}
export function rootRun(
attempt = 1,
conclusion: string | null = "failure",
status = conclusion === null ? "in_progress" : "completed",
) {
return {
actor: { login: "github-actions[bot]" },
conclusion,
display_title: "Full Release Validation",
event: "workflow_dispatch",
head_branch: SOURCE_REF,
head_sha: SHA,
id: 77,
path: ".github/workflows/full-release-validation.yml",
repository: { full_name: REPOSITORY },
run_attempt: attempt,
status,
triggering_actor: { login: attempt === 1 ? "github-actions[bot]" : "release-operator" },
};
}
+14 -1726
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
export function extractFunctionBody(source: string, name: string): string {
const lines = source.split(/\r?\n/u);
const start = lines.indexOf(`function ${name} {`);
if (start < 0) {
throw new Error(`Missing PowerShell function body ${name}`);
}
const body: string[] = [];
let hereStringEnd: string | undefined;
for (const line of lines.slice(start + 1)) {
if (hereStringEnd) {
if (line.startsWith(hereStringEnd)) {
hereStringEnd = undefined;
}
} else if (line === "}") {
return `${body.join("\n")}\n`;
} else {
const hereStringStart = /(?:^|[\s=])@(['"])\s*$/u.exec(line);
if (hereStringStart) {
hereStringEnd = `${hereStringStart[1]}@`;
}
}
body.push(line);
}
throw new Error(`Missing PowerShell function body ${name}`);
}
File diff suppressed because it is too large Load Diff