diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34a1f6e3300d..1f0534d4ed2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4136,7 +4136,6 @@ jobs: needs: [preflight] if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }} runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON((contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt == 1 && needs.preflight.outputs.hybrid_hosted_checks == 'true' && contains(fromJSON('["runtime-topology-architecture","dependencies"]'), matrix.group)) && 'ubuntu-24.04' || (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && (github.run_attempt > 1 || !contains(fromJSON('["extension-package-boundary","runtime-topology-architecture","plugin-sdk-api-diff","dependencies"]'), matrix.group))) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04')))) || ((contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt == 1 && needs.preflight.outputs.hybrid_hosted_checks == 'true' && contains(fromJSON('["runtime-topology-architecture","dependencies"]'), matrix.group)) && 'ubuntu-24.04' || (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && (github.run_attempt > 1 || !contains(fromJSON('["extension-package-boundary","runtime-topology-architecture","plugin-sdk-api-diff","dependencies"]'), matrix.group))) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04')) }} - # Cold package validation exceeds 20 min on 4-CPU hosted runners. timeout-minutes: ${{ matrix.group == 'extension-package-boundary' && 30 || 20 }} strategy: fail-fast: false @@ -4168,10 +4167,8 @@ jobs: if: &additional_boundary_sticky_disk_gate matrix.group == 'extension-package-boundary' && steps.extension-boundary-inputs.outputs.enabled == 'true' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && !contains(fromJSON('["hybrid","runson"]'), vars.OPENCLAW_CI_RUNNER_BACKEND) && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') uses: useblacksmith/stickydisk@94697d49e77d0dd78b77deb85ad3de63a28b4b8a # v1.7.1 with: - # Stable disk keys avoid quota exhaustion; markers detect source/toolchain changes. key: ${{ github.repository }}-ext-boundary-v2 path: /var/tmp/openclaw-ext-boundary - # Only protected successful pushes publish. Explicit commit:true refreshes same-size changes. commit: ${{ github.event_name != 'pull_request' && 'true' || 'false' }} - name: Restore extension boundary artifacts from sticky disk @@ -4185,8 +4182,6 @@ jobs: echo "restored=false" >> "$GITHUB_OUTPUT" exit 0 fi - # Restore an exact-commit transport snapshot here. The preparer still - # validates per-owner content, topology, toolchain and output inventories. current_fingerprint="${{ steps.extension-boundary-inputs.outputs.fingerprint }}" if [ ! -f "$sticky_root/.source-fingerprint" ] || [ "$current_fingerprint" != "$(cat "$sticky_root/.source-fingerprint")" ]; then echo "boundary source trees changed since snapshot; building cold" @@ -4225,8 +4220,9 @@ jobs: RUN_PROMPT_SNAPSHOTS: ${{ needs.preflight.outputs.run_prompt_snapshots }} OPENCLAW_ADDITIONAL_BOUNDARY_SHARD: "" OPENCLAW_ADDITIONAL_BOUNDARY_CONCURRENCY: 4 - # Runner labels are not CPU counts; preserve the CI worker budget. OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY: 16 + OPENCLAW_CI_EXTENSION_BOUNDARY_BASE: ${{ needs.preflight.outputs.diff_base_revision }} + OPENCLAW_CI_EXTENSION_BOUNDARY_FULL: ${{ vars.OPENCLAW_CI_EXTENSION_BOUNDARY_FULL }} shell: bash run: | set -euo pipefail diff --git a/docs/ci/scope-and-routing/selection.md b/docs/ci/scope-and-routing/selection.md index 99b2204b6f36..105fe05ed756 100644 --- a/docs/ci/scope-and-routing/selection.md +++ b/docs/ci/scope-and-routing/selection.md @@ -88,6 +88,7 @@ The iOS, macOS, and both shared OpenClawKit Periphery scans use Xcode 27 on GitH - **Preflight diagnostics** log `Node test plan changed-set` with the selected row count and any bounded owner-selection reason. Missing or unbounded PR plans fail preflight; they never become the full compact matrix. The logged changed-path manifest supplies replay inputs. Hourly main and ordinary manual/release validation retain complete inventories through their existing owners. - **PR check families** select static checks and guards from their own changed-path owners, independently of whether Node test targeting finds a precise plan. Lint and formatting reuse the local changed-check owner; lint configuration changes retain full lint. Semantic lint runs changed files and their transitive import consumers, including type-only imports and workspace/path aliases, through the existing shard owner. Each row receives only its assigned file list; workers do not rediscover the graph. Affected root tests retain their central compiler-config admission. Deleted, ambient, unsupported, and oversized selections retain full lint. Hourly main and Full Release Validation continue running full repository lint. Hosted profiles keep selected files in their existing stripes, while scripts, root tests, formatting, and localization checks retain their central owner. Preflight remains dependency-free and owns admission, static-check selection, and the original row/resource templates. For admitted narrow PRs, the hosted `check-plan` job installs through the existing Node setup owner, then materializes selected-file lint and compiler facts. It validates compiler ownership and selects every graph that consumes a changed file, including erased type imports into UI, plugins, scripts, and tests. Its consumers require a successful plan; failure cannot silently select a full fallback, and `openclaw/ci-gate` requires the planner itself. Existing production, test, and stripe jobs retain their runner placement and compiler concurrency; only rows with selected graphs run. GitHub and hybrid keep changed core-test consumers in their canonical stripes; the central row owns the selected extensions, scripts, and root-test graphs. Markdown and UI styles do not widen a mixed TypeScript change. Deleted, ambient, configuration, and unclassified inputs retain all compiler graphs. Shared fixtures and scanner policy inputs retain conservative check families. Conflict-marker and wall-clock deprecation guards run after Node setup in the required planner job. Other guards retain their generated native inputs. SDK subpath exports, extension import boundaries, declaration compile/canary proof, protocol generation, schema drift, and Knip remain blocking. Plugin and channel runtime contract tests use the exact changed-owner Node plan, including their watched SDK documentation and packaged skills. Narrow code changes run coercion checks once in the guard job. Main, ordinary manual CI, and historical targets skip this additional planner and retain their full check families. Static full fallback never widens runtime families. Bundled config metadata follows the shared schema owner map. The bundled/protocol row always verifies protocol generation; its full bundled runtime suite runs only on hourly main and ordinary manual/release validation. Specialized Bun launcher proof opts in for its direct launcher and module-generation owners. Startup, plugin, and channel tests retain their canonical configs within the changed-owner plan. - **Browser integration on PRs** uses the Control UI file selection above while retaining complete ordinary real-Gateway and browser-extension families when their existing owners change. Unrelated families remain omitted unless protected or affected tests select a precise subset. Named release-only real-Gateway compositions retain their existing opt-in policy. Existing row caps, worker limits, serial/parallel ownership, and runner routes stay unchanged; small Control UI selections omit empty rows. Empty real-Gateway phase groups are removed; a desktop-only carrier keeps its required desktop proof without invoking a test phase with no files. Hourly main and Full Release Validation retain the full Control UI E2E set; historical manual targets without the selector retain their existing full target-owned path. +- **Extension package boundary selection** uses the PR's own merge-base diff, independently of restored receipt age. It checks directly touched plugin packages. Core/SDK declaration and shared compiler/dependency changes add a fixed smoke set: Telegram, Codex and Slack, chosen to cover 119 distinct directly consumed public SDK entries. Changes to a public SDK entry file also select packages that directly import that entry, including type-only imports. Transitive declaration consumers and main-only drift are intentionally left to the complete hourly main and manual/release checks. Selected packages retain full compiler diagnostics and normal receipt validation; the negative boundary canary always runs. The job summary lists selected and skipped packages with reasons. Unset repository variable `OPENCLAW_CI_EXTENSION_BOUNDARY_FULL` enables this aggressive policy; `true`, `1` or `full` restores complete PR checks. An unavailable comparison or import inventory also retains full scope. - **Extension selection** uses exact tests from the changed plugin owner, transitive test importers, protected regressions, and explicit policy watches. Global dependency, shared-runtime, SDK, and planner inputs do not append a whole-plugin fallback. The existing Plugin Prerelease workflow owns the complete extension runtime inventory hourly and in Full Release Validation; normal CI does not append a second partial inventory. - **PR builds** select `build-artifacts` for a dist-dependent row, an affected build/package owner test, or an individually selected built-process proof. Pipeline ownership comes from the existing changed-target map for build, declaration, package-tarball, and dist-artifact tests; generic runtime changes do not request the full artifact job. Source boundary guards and affected channel tests keep their Node owners. An artifact build does not also select an unrelated dist boundary, channel family, or every process verifier. - **PR wrapper extraction** selects `pr-worktree-provision.test.ts` when the wrapper, its library, or a file in `scripts/pr-lib/wrapper-components.txt` changes. This manifest-derived policy watch supplements ordinary source tests because filesystem copying is invisible to the import graph. Manifest-only changes also run provisioning, including its duplicate-inventory and eager runtime import-closure checks. diff --git a/scripts/check-extension-package-tsc-boundary.mts b/scripts/check-extension-package-tsc-boundary.mts index ac997400121b..71d918572d8d 100644 --- a/scripts/check-extension-package-tsc-boundary.mts +++ b/scripts/check-extension-package-tsc-boundary.mts @@ -4,6 +4,7 @@ import type { ChildProcess } from "node:child_process"; import type { EventEmitter } from "node:events"; import { + appendFileSync, existsSync, mkdirSync, readdirSync, @@ -34,6 +35,10 @@ import { import { toErrorObject } from "./lib/error-format.mts"; import { BOUNDARY_CACHE_ROOT, BoundaryInputSnapshot } from "./lib/extension-boundary-inputs.mts"; import { prepareExtensionBoundaryProjects } from "./lib/extension-boundary-projects.mts"; +import { + formatBoundarySelection, + resolveExtensionBoundarySelection, +} from "./lib/extension-boundary-selection.mts"; import { classifyBundledExtensionSourcePath } from "./lib/extension-source-classifier.mts"; import { runManagedCommand, @@ -496,6 +501,15 @@ function resolveBoundaryTsStampPath(extensionId: string, rootDir = repoRoot) { return resolve(rootDir, BOUNDARY_CACHE_ROOT, "compile", `${extensionId}.json`); } async function runCompileCheck(extensionIds: string[]) { + if (extensionIds.length === 0) { + return { + prepElapsedMs: 0, + compileCount: 0, + skippedCompileCount: 0, + compileElapsedMs: 0, + compileTimings: [], + }; + } const prepStartedAt = Date.now(); process.stdout.write( `preparing plugin-sdk boundary artifacts for ${extensionIds.length} plugins\n`, @@ -729,8 +743,14 @@ async function runBoundaryCheck(argv: string[]) { try { cleanupCanaryArtifactsForExtensions(cleanupExtensionIds); if (mode === "all" || mode === "compile") { + const selection = resolveExtensionBoundarySelection(repoRoot, optInExtensionIds); + const summary = formatBoundarySelection(selection); + process.stdout.write(summary); + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); + } ({ prepElapsedMs, compileCount, skippedCompileCount, compileElapsedMs, compileTimings } = - await runCompileCheck(optInExtensionIds)); + await runCompileCheck(selection.selected.map((row) => row.package))); } if (shouldRunCanary) { ({ canaryElapsedMs } = await runCanaryCheck(canaryExtensionIds)); diff --git a/scripts/lib/extension-boundary-selection.mts b/scripts/lib/extension-boundary-selection.mts new file mode 100644 index 000000000000..f7b56fb6cfb7 --- /dev/null +++ b/scripts/lib/extension-boundary-selection.mts @@ -0,0 +1,203 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { collectModuleReferencesFromSource } from "./guard-inventory-utils.mjs"; +import { createNativeTypeScriptParser } from "./native-typescript.mts"; + +type Change = { path: string; status: string }; +type Selection = { + mode: "full" | "affected"; + reason: string; + base?: string; + selected: { package: string; reason: string }[]; + skipped: { package: string; reason: string }[]; +}; + +function fullSelection(extensionIds: string[], reason: string): Selection { + return { + mode: "full", + reason, + selected: extensionIds.map((id) => ({ package: id, reason })), + skipped: [], + }; +} + +// Greedy direct public-SDK coverage: 98 + 16 + 5 distinct entries across these packages. +const SMOKE_PACKAGES = ["telegram", "codex", "slack"]; +const SOURCE = /\.[cm]?[jt]sx?$/u; + +function publicEntries(rootDir: string, base?: string) { + const read = (name: string, revision?: string): string[] => { + const file = `scripts/lib/plugin-sdk-${name}.json`; + return JSON.parse( + revision + ? execFileSync("git", ["show", `${revision}:${file}`], { cwd: rootDir, encoding: "utf8" }) + : readFileSync(resolve(rootDir, file), "utf8"), + ); + }; + return new Set( + [undefined, ...(base ? [base] : [])].flatMap((revision) => { + const privateEntries = new Set(read("private-local-only-subpaths", revision)); + return read("entrypoints", revision).filter((entry) => !privateEntries.has(entry)); + }), + ); +} + +/** PR scope intentionally omits transitive declaration consumers; hourly checks them all. */ +export function selectAffectedBoundaryPackages( + rootDir: string, + extensionIds: string[], + changes: Change[], + base?: string, +): Selection { + const reasons = new Map(); + let sharedChange: string | undefined; + const changedEntries = new Set(); + const entries = changes.some(({ path }) => path.startsWith("src/plugin-sdk/")) + ? publicEntries(rootDir, base) + : new Set(); + for (const { path: file } of changes) { + const owner = /^extensions\/([^/]+)\//u.exec(file)?.[1]; + if (owner && extensionIds.includes(owner)) { + reasons.set(owner, `PR changes ${file}`); + } + const entry = /^src\/plugin-sdk\/([^/]+)\.ts$/u.exec(file)?.[1]; + if (entry && entries.has(entry)) { + changedEntries.add(`openclaw/plugin-sdk/${entry}`); + } + if ( + (/^(?:src|packages)\//u.test(file) && + SOURCE.test(file) && + !/\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(file)) || + /^(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|tsconfig[^/]*\.json)$/u.test(file) || + /^scripts\/(?:lib\/plugin-sdk-|(?:prepare|check|compile)-extension.*boundary)/u.test(file) + ) { + sharedChange ??= file; + } + } + if (sharedChange) { + for (const id of SMOKE_PACKAGES) { + if (extensionIds.includes(id) && !reasons.has(id)) { + reasons.set(id, `SDK smoke sample for ${sharedChange}`); + } + } + } + if (changedEntries.size > 0) { + const files = execFileSync("git", ["ls-files", "-z", "--", "extensions"], { + cwd: rootDir, + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + }) + .split("\0") + .filter((file) => SOURCE.test(file) && !/\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(file)); + const sources: { fileName: string; text: string; owner: string }[] = []; + for (const file of files) { + const owner = file.split("/")[1]!; + if ( + !extensionIds.includes(owner) || + reasons.has(owner) || + !existsSync(resolve(rootDir, file)) + ) { + continue; + } + if (!lstatSync(resolve(rootDir, file)).isFile()) { + reasons.set(owner, `nonregular package source: ${file}`); + continue; + } + const source = readFileSync(resolve(rootDir, file), "utf8"); + if (!source.includes("openclaw/plugin-sdk/") && !source.includes("\\")) { + continue; + } + sources.push({ fileName: file, text: source, owner }); + } + using parser = createNativeTypeScriptParser({ cwd: rootDir }); + for (const [index, sourceFile] of parser.parseSourceFiles(sources).entries()) { + const { owner, fileName } = sources[index]!; + const direct = collectModuleReferencesFromSource(sourceFile, { + acceptSpecifier: (specifier) => changedEntries.has(specifier), + })[0]; + if (direct && !reasons.has(owner)) { + reasons.set( + owner, + `direct import of changed public entry ${direct.specifier}: ${fileName}`, + ); + } + } + } + + return { + mode: "affected", + reason: "PR diff: touched packages, SDK smoke sample and direct public-entry consumers", + selected: extensionIds.flatMap((id) => { + const reason = reasons.get(id); + return reason ? [{ package: id, reason }] : []; + }), + skipped: extensionIds + .filter((id) => !reasons.has(id)) + .map((id) => ({ + package: id, + reason: "outside PR selection; transitive consumers and main drift covered hourly", + })), + }; +} + +export function resolveExtensionBoundarySelection( + rootDir: string, + extensionIds: string[], + env: NodeJS.ProcessEnv = process.env, +): Selection { + if (env.GITHUB_EVENT_NAME !== "pull_request") { + return fullSelection(extensionIds, "full check outside pull_request"); + } + if (["1", "true", "full"].includes(env.OPENCLAW_CI_EXTENSION_BOUNDARY_FULL?.trim() ?? "")) { + return fullSelection(extensionIds, "OPENCLAW_CI_EXTENSION_BOUNDARY_FULL kill switch"); + } + const revision = env.OPENCLAW_CI_EXTENSION_BOUNDARY_BASE ?? ""; + if (!/^[a-f0-9]{40}$/u.test(revision)) { + return fullSelection(extensionIds, "missing pinned PR comparison base"); + } + const git = (args: string[]) => + execFileSync("git", args, { cwd: rootDir, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }); + try { + const base = git(["merge-base", revision, "HEAD"]).trim(); + if (base !== revision) { + return fullSelection(extensionIds, "PR comparison base is not an ancestor of tested HEAD"); + } + const fields = git([ + "diff", + "--name-status", + "--no-renames", + "-z", + `${base}...HEAD`, + "--", + ]).split("\0"); + fields.pop(); + if (fields.length % 2 !== 0) { + return fullSelection(extensionIds, "incomplete PR diff"); + } + const changes: Change[] = []; + for (let index = 0; index < fields.length; index += 2) { + changes.push({ status: fields[index]!, path: fields[index + 1]! }); + } + return { ...selectAffectedBoundaryPackages(rootDir, extensionIds, changes, base), base }; + } catch { + return fullSelection(extensionIds, "PR diff or direct-import inventory unavailable"); + } +} + +export function formatBoundarySelection(selection: Selection) { + const safe = (text: string) => text.replace(/[|\r\n<>`]/gu, " "); + return [ + "## Extension package boundary selection", + "", + `${selection.selected.length} selected, ${selection.skipped.length} skipped. ${selection.reason}.`, + ...(selection.base ? [`Comparison base: ${selection.base}.`] : []), + "The negative boundary canary still runs. Selected packages retain full diagnostics and receipt validation.", + "", + "| Package | Decision | Reason |", + "| --- | --- | --- |", + ...selection.selected.map((row) => `| ${safe(row.package)} | selected | ${safe(row.reason)} |`), + ...selection.skipped.map((row) => `| ${safe(row.package)} | skipped | ${safe(row.reason)} |`), + "", + ].join("\n"); +} diff --git a/test/scripts/extension-boundary-selection.test.ts b/test/scripts/extension-boundary-selection.test.ts new file mode 100644 index 000000000000..57199b3075c3 --- /dev/null +++ b/test/scripts/extension-boundary-selection.test.ts @@ -0,0 +1,226 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { parse } from "yaml"; +import { + formatBoundarySelection, + resolveExtensionBoundarySelection, + selectAffectedBoundaryPackages, +} from "../../scripts/lib/extension-boundary-selection.mts"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); +const packages = ["consumer", "direct", "unrelated", "telegram", "codex", "slack"]; + +function fixture() { + const root = tempDirs.make("boundary-selection-"); + const write = (file: string, contents: string) => { + mkdirSync(dirname(join(root, file)), { recursive: true }); + writeFileSync(join(root, file), contents); + }; + write("scripts/lib/plugin-sdk-entrypoints.json", JSON.stringify(["value", "private"])); + write("scripts/lib/plugin-sdk-private-local-only-subpaths.json", JSON.stringify(["private"])); + write("package.json", '{"type":"module"}'); + write( + "tsconfig.json", + JSON.stringify({ compilerOptions: { paths: { "sdk/*": ["src/plugin-sdk/*.ts"] } } }), + ); + write("src/old.ts", "export type Value = string;\n"); + write("src/current.ts", "export type Value = number;\n"); + write("src/plugin-sdk/value.ts", 'export type { Value } from "../current.js";\n'); + write( + "extensions/consumer/index.ts", + 'import type { Value } from "openclaw/plugin-sdk/value";\nexport type Result = Value[];\n', + ); + write("extensions/direct/index.ts", "export const direct = 1;\n"); + write("extensions/unrelated/index.ts", "export const unrelated = 1;\n"); + const git = (...args: string[]) => + execFileSync("git", args, { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + GIT_AUTHOR_NAME: "Boundary fixture", + GIT_AUTHOR_EMAIL: "fixture@example.invalid", + GIT_COMMITTER_NAME: "Boundary fixture", + GIT_COMMITTER_EMAIL: "fixture@example.invalid", + }, + }).trim(); + git("init", "-q"); + const commit = () => { + git("add", "."); + git("-c", "core.hooksPath=/dev/null", "commit", "-qm", "fixture"); + return git("rev-parse", "HEAD"); + }; + return { root, write, git, commit }; +} + +describe("extension package PR selection", () => { + it("selects touched packages and a bounded smoke set instead of transitive core consumers", () => { + const { root, commit } = fixture(); + commit(); + const selection = selectAffectedBoundaryPackages(root, packages, [ + { status: "M", path: "src/current.ts" }, + { status: "M", path: "extensions/direct/index.ts" }, + ]); + expect(selection.selected.map((row) => row.package)).toEqual([ + "direct", + "telegram", + "codex", + "slack", + ]); + expect(selection.skipped.map((row) => row.package)).toEqual(["consumer", "unrelated"]); + const summary = formatBoundarySelection(selection); + expect(summary).toContain("| telegram | selected | SDK smoke sample"); + expect(summary).toContain("| consumer | skipped | outside PR selection;"); + expect(summary).toContain("negative boundary canary still runs"); + }); + + it.each([ + 'import type { Value } from "openclaw/plugin-sdk/value"; export type Result = Value;', + 'export type { Value } from "openclaw/plugin-sdk/value";', + 'export type Result = import("openclaw/plugin-sdk/value").Value;', + 'export const load = () => import("openclaw/plugin-sdk/value");', + 'import type { Value } from "openclaw/plugin-sdk/value"; export const options = { onRequest: true ? async (value, context): Promise => value : undefined };', + ])("selects direct public-entry consumers for %s", (source) => { + const { root, write, commit } = fixture(); + write("extensions/consumer/index.ts", source); + write( + "extensions/unrelated/index.ts", + '// import "openclaw/plugin-sdk/value";\nexport const text = \'import "openclaw/plugin-sdk/value";\';\n', + ); + commit(); + const selection = selectAffectedBoundaryPackages(root, packages, [ + { status: "M", path: "src/plugin-sdk/value.ts" }, + ]); + expect(selection.selected.map((row) => row.package)).toEqual([ + "consumer", + "telegram", + "codex", + "slack", + ]); + expect(selection.selected[0]?.reason).toContain("direct import of changed public entry"); + }); + + it("uses the base inventory for a deleted public entry, while private entries only select smoke", () => { + const { root, write, commit } = fixture(); + const base = commit(); + rmSync(join(root, "src/plugin-sdk/value.ts")); + write("scripts/lib/plugin-sdk-entrypoints.json", '["private"]'); + commit(); + const deleted = resolveExtensionBoundarySelection(root, packages, { + GITHUB_EVENT_NAME: "pull_request", + OPENCLAW_CI_EXTENSION_BOUNDARY_BASE: base, + }); + expect(deleted.selected.map((row) => row.package)).toEqual([ + "consumer", + "telegram", + "codex", + "slack", + ]); + expect( + selectAffectedBoundaryPackages(root, packages, [ + { status: "M", path: "src/plugin-sdk/private.ts" }, + ]).selected.map((row) => row.package), + ).toEqual(["telegram", "codex", "slack"]); + }); + + it("excludes main drift since the cache seed from the PR contribution", () => { + const { root, write, commit } = fixture(); + commit(); + write("src/plugin-sdk/value.ts", 'export type { Value } from "../old.js";\n'); + const base = commit(); + write("extensions/direct/index.ts", "export const direct = 2;\n"); + commit(); + const selection = resolveExtensionBoundarySelection(root, packages, { + GITHUB_EVENT_NAME: "pull_request", + OPENCLAW_CI_EXTENSION_BOUNDARY_BASE: base, + }); + expect(selection.base).toBe(base); + expect(selection.selected.map((row) => row.package)).toEqual(["direct"]); + }); + + it("keeps schedule, release, the kill switch and uncertain comparisons full", () => { + const { root, commit } = fixture(); + const base = commit(); + for (const env of [ + { GITHUB_EVENT_NAME: "schedule" }, + { GITHUB_EVENT_NAME: "workflow_dispatch" }, + { GITHUB_EVENT_NAME: "release" }, + { GITHUB_EVENT_NAME: "pull_request" }, + ...["1", "true", "full"].map((value) => ({ + GITHUB_EVENT_NAME: "pull_request", + OPENCLAW_CI_EXTENSION_BOUNDARY_BASE: base, + OPENCLAW_CI_EXTENSION_BOUNDARY_FULL: value, + })), + ]) { + const selection = resolveExtensionBoundarySelection(root, packages, env); + expect(selection.mode).toBe("full"); + expect(selection.selected.map((row) => row.package)).toEqual(packages); + expect(selection.skipped).toEqual([]); + } + }); + + it("bounds declaration, dependency and module-membership changes without widening unrelated files", () => { + const { root, commit } = fixture(); + commit(); + for (const change of [ + { status: "M", path: "pnpm-lock.yaml" }, + { status: "M", path: "src/types/globals.d.ts" }, + { status: "D", path: "src/current.ts" }, + { status: "A", path: "src/added.ts" }, + ]) { + expect( + selectAffectedBoundaryPackages(root, packages, [change]).selected.map((row) => row.package), + ).toEqual(["telegram", "codex", "slack"]); + } + for (const path of ["extensions/direct/tsconfig.json", "extensions/direct/index.ts"]) { + expect( + selectAffectedBoundaryPackages(root, packages, [{ status: "T", path }]).selected.map( + (row) => row.package, + ), + ).toEqual(["direct"]); + } + for (const path of ["README.md", "ui/src/app.ts", "src/current.test.ts"]) { + expect( + selectAffectedBoundaryPackages(root, packages, [{ status: "M", path }]).selected, + ).toEqual([]); + } + }); + + it("retains the touched owner for deleted Browser/XAI aliases and nonregular sources", () => { + const { root, write, commit } = fixture(); + for (const extension of ["browser", "xai"]) { + write(`extensions/${extension}/removed.ts`, "export {};\n"); + } + const base = commit(); + rmSync(join(root, "extensions/browser/removed.ts")); + rmSync(join(root, "extensions/xai/removed.ts")); + symlinkSync("../../src/current.ts", join(root, "extensions/direct/linked.ts")); + commit(); + const selection = resolveExtensionBoundarySelection(root, [...packages, "browser", "xai"], { + GITHUB_EVENT_NAME: "pull_request", + OPENCLAW_CI_EXTENSION_BOUNDARY_BASE: base, + }); + expect(selection.selected.map((row) => row.package)).toEqual(["direct", "browser", "xai"]); + }); + + it("wires the repository kill switch, comparison and negative canary into the required boundary job", () => { + const workflow = parse( + readFileSync(new URL("../../.github/workflows/ci.yml", import.meta.url), "utf8"), + ); + const step = workflow.jobs["check-additional-shard"].steps.find( + (entry: { name?: string }) => entry.name === "Run additional check shard", + ); + expect(step.env.OPENCLAW_CI_EXTENSION_BOUNDARY_FULL).toBe( + "${{ vars.OPENCLAW_CI_EXTENSION_BOUNDARY_FULL }}", + ); + expect(step.env.OPENCLAW_CI_EXTENSION_BOUNDARY_BASE).toBe( + "${{ needs.preflight.outputs.diff_base_revision }}", + ); + expect(step.run).toContain( + 'run_check "test:extensions:package-boundary:canary" pnpm run test:extensions:package-boundary:canary', + ); + }); +});