fix(ci): skip Periphery scans for unrelated PR changes (#107991)

* fix(ci): scope Periphery to PR merge changes

* test(ci): align Periphery changed-test routing
This commit is contained in:
Mason Huang
2026-07-19 16:52:44 +08:00
committed by GitHub
parent 8631832048
commit c26d3cfbd0
8 changed files with 281 additions and 213 deletions
+4 -13
View File
@@ -26,18 +26,11 @@ jobs:
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 1
fetch-depth: 2
fetch-tags: false
persist-credentials: false
submodules: false
- name: Ensure base commit
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event.pull_request.base.ref }}
- name: Detect changed paths
id: scope
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
@@ -52,14 +45,12 @@ jobs:
return;
}
const baseSha = context.payload.pull_request?.base?.sha;
if (!baseSha) {
throw new Error("missing pull request base SHA");
}
// pull_request checks out GitHub's synthetic merge commit. Its first
// parent is the exact base tree used to produce the tested result.
const result = await exec.getExecOutput("git", [
"diff",
"--quiet",
baseSha,
"HEAD^1",
"HEAD",
"--",
"apps/ios/",
+4 -13
View File
@@ -26,18 +26,11 @@ jobs:
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 1
fetch-depth: 2
fetch-tags: false
persist-credentials: false
submodules: false
- name: Ensure base commit
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event.pull_request.base.ref }}
- name: Detect changed paths
id: scope
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
@@ -52,14 +45,12 @@ jobs:
return;
}
const baseSha = context.payload.pull_request?.base?.sha;
if (!baseSha) {
throw new Error("missing pull request base SHA");
}
// pull_request checks out GitHub's synthetic merge commit. Its first
// parent is the exact base tree used to produce the tested result.
const result = await exec.getExecOutput("git", [
"diff",
"--quiet",
baseSha,
"HEAD^1",
"HEAD",
"--",
"apps/macos/",
@@ -26,18 +26,11 @@ jobs:
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 1
fetch-depth: 2
fetch-tags: false
persist-credentials: false
submodules: false
- name: Ensure base commit
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event.pull_request.base.ref }}
- name: Detect changed paths
id: scope
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
@@ -52,14 +45,12 @@ jobs:
return;
}
const baseSha = context.payload.pull_request?.base?.sha;
if (!baseSha) {
throw new Error("missing pull request base SHA");
}
// pull_request checks out GitHub's synthetic merge commit. Its first
// parent is the exact base tree used to produce the tested result.
const result = await exec.getExecOutput("git", [
"diff",
"--quiet",
baseSha,
"HEAD^1",
"HEAD",
"--",
"apps/ios/",
+15 -3
View File
@@ -520,14 +520,26 @@ const GITHUB_WORKFLOW_OWNER_TEST_TARGETS = new Map([
".github/workflows/ios-periphery-comment.yml",
["test/scripts/ios-periphery-comment-workflow.test.ts"],
],
[".github/workflows/ios-periphery.yml", ["test/scripts/ios-periphery-comment-workflow.test.ts"]],
[
".github/workflows/ios-periphery.yml",
[
"test/scripts/ios-periphery-comment-workflow.test.ts",
"test/scripts/periphery-scope-workflows.test.ts",
],
],
[
".github/workflows/macos-periphery.yml",
["test/scripts/ios-periphery-comment-workflow.test.ts"],
[
"test/scripts/ios-periphery-comment-workflow.test.ts",
"test/scripts/periphery-scope-workflows.test.ts",
],
],
[
".github/workflows/shared-openclawkit-periphery.yml",
["test/scripts/periphery-intersection.test.ts"],
[
"test/scripts/periphery-intersection.test.ts",
"test/scripts/periphery-scope-workflows.test.ts",
],
],
[
".github/workflows/live-media-runner-image.yml",
@@ -88,74 +88,6 @@ function commenterScript(): string {
return script;
}
function scopeScript(): string {
const workflow = parse(readFileSync(PRODUCER_WORKFLOW_PATH, "utf8")) as ProducerWorkflow;
const step = workflow.jobs?.scope?.steps?.find((candidate) => candidate.id === "scope");
const script = step?.with?.script;
if (!script) {
throw new Error("missing iOS Periphery scope script");
}
return script;
}
async function runScope(options: {
diffExitCode?: number;
draft?: boolean;
eventName?: string;
files?: Array<string | { filename: string; previous_filename?: string }>;
}): Promise<string | undefined> {
const outputs = new Map<string, string>();
const core = {
setOutput(name: string, value: string) {
outputs.set(name, value);
},
};
const context = {
eventName: options.eventName ?? "pull_request",
payload: {
pull_request: {
base: { sha: "base-sha" },
draft: options.draft ?? false,
number: 123,
},
},
repo: {
owner: "openclaw",
repo: "openclaw",
},
};
const exec = {
async getExecOutput(command: string, args: string[]) {
if (command !== "git" || args.slice(0, 5).join(" ") !== "diff --quiet base-sha HEAD --") {
throw new Error(`unexpected scope command: ${command} ${args.join(" ")}`);
}
if (options.diffExitCode !== undefined) {
return { exitCode: options.diffExitCode };
}
const pathspecs = args.slice(5);
const filenames = (options.files ?? []).flatMap((file) =>
typeof file === "string"
? [file]
: [file.filename, file.previous_filename].filter((name): name is string => Boolean(name)),
);
const changed = filenames.some((filename) =>
pathspecs.some((pathspec) =>
pathspec.endsWith("/") ? filename.startsWith(pathspec) : filename === pathspec,
),
);
return { exitCode: changed ? 1 : 0 };
},
};
const execute = compileFunction(`return (async () => {\n${scopeScript()}\n})();`, [
"context",
"core",
"exec",
]) as (context: unknown, core: unknown, exec: unknown) => Promise<void>;
await execute(context, core, exec);
return outputs.get("should-scan");
}
async function runCommenter(
artifact: Artifact,
archiveData: Buffer,
@@ -476,69 +408,6 @@ describe("iOS Periphery comment workflow", () => {
expect(workflow.jobs?.scan?.["runs-on"]).toContain("github.run_attempt > 1");
}
});
it("runs scope detection for PR transitions that can clear stale findings", () => {
const workflow = parse(readFileSync(PRODUCER_WORKFLOW_PATH, "utf8")) as ProducerWorkflow;
expect(workflow.on?.pull_request?.types).toContain("converted_to_draft");
expect(workflow.on?.pull_request?.paths).toBeUndefined();
expect(() =>
compileFunction(`return (async () => {\n${scopeScript()}\n})();`, [
"context",
"core",
"exec",
]),
).not.toThrow();
expect(scopeScript()).toContain("exec.getExecOutput");
expect(scopeScript()).not.toContain("pulls.listFiles");
});
it.each([
{
expected: "false",
files: ["apps/ios/Sources/Test.swift"],
name: "draft pull request",
options: { draft: true },
},
{
expected: "true",
files: ["apps/ios/Sources/Test.swift"],
name: "iOS change",
options: {},
},
{
expected: "false",
files: ["docs/index.md"],
name: "out-of-scope change",
options: {},
},
{
expected: "true",
files: [
{
filename: "docs/Moved.swift",
previous_filename: "apps/ios/Sources/Moved.swift",
},
],
name: "iOS file renamed out of scope",
options: {},
},
{
expected: "true",
files: [],
name: "manual dispatch",
options: { eventName: "workflow_dispatch" },
},
])("sets scope output for $name", async ({ expected, files, options }) => {
await expect(runScope({ ...options, files })).resolves.toBe(expected);
});
it("fails closed when git cannot compare the base and head", async () => {
await expect(runScope({ diffExitCode: 128 })).rejects.toThrow(
"git diff failed with exit code 128",
);
});
it("accepts a valid small Periphery artifact", async () => {
const archive = makeZip({
"periphery.json": "[]\n",
@@ -1,7 +1,6 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { compileFunction } from "node:vm";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
import {
@@ -167,43 +166,4 @@ describe("shared OpenClawKit Periphery workflow", () => {
);
expect(macosScan?.run).not.toContain("--exclude-tests");
});
it("scopes native consumer, shared package, and workflow changes", async () => {
const script = workflow.jobs?.scope?.steps?.find((step) => step.id === "scope")?.with?.script;
expect(script).toBeTruthy();
const execute = compileFunction(`return (async () => {\n${script}\n})();`, [
"context",
"core",
"exec",
]) as (context: unknown, core: unknown, exec: unknown) => Promise<void>;
for (const filename of [
"apps/ios/Sources/App.swift",
"apps/macos/Sources/OpenClaw/App.swift",
"apps/shared/OpenClawKit/Sources/OpenClawKit/Example.swift",
WORKFLOW_PATH,
]) {
const outputs = new Map<string, string>();
await execute(
{
eventName: "pull_request",
payload: { pull_request: { base: { sha: "base-sha" }, draft: false, number: 1 } },
repo: {},
},
{ setOutput: (name: string, value: string) => outputs.set(name, value) },
{
async getExecOutput(command: string, args: string[]) {
expect(command).toBe("git");
expect(args.slice(0, 5)).toEqual(["diff", "--quiet", "base-sha", "HEAD", "--"]);
const pathspecs = args.slice(5);
const changed = pathspecs.some((pathspec) =>
pathspec.endsWith("/") ? filename.startsWith(pathspec) : filename === pathspec,
);
return { exitCode: changed ? 1 : 0 };
},
},
);
expect(outputs.get("should-scan"), filename).toBe("true");
}
});
});
@@ -0,0 +1,218 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { compileFunction } from "node:vm";
import { afterEach, describe, expect, it } from "vitest";
import { parse } from "yaml";
import { cleanupTempDirs, makeTempRepoRoot } from "../helpers/temp-repo.js";
const WORKFLOW_CASES = [
{
name: "iOS",
path: ".github/workflows/ios-periphery.yml",
scopedPath: "apps/ios/Sources/Test.swift",
},
{
name: "macOS",
path: ".github/workflows/macos-periphery.yml",
scopedPath: "apps/macos/Sources/OpenClaw/Test.swift",
},
{
name: "shared OpenClawKit",
path: ".github/workflows/shared-openclawkit-periphery.yml",
scopedPath: "apps/shared/OpenClawKit/Sources/OpenClawKit/Test.swift",
},
] as const;
type WorkflowStep = {
id?: string;
name?: string;
with?: {
"fetch-depth"?: number;
script?: string;
};
};
type ScopeWorkflow = {
on?: {
pull_request?: {
paths?: string[];
types?: string[];
};
};
jobs?: {
scope?: {
steps?: WorkflowStep[];
};
};
};
type ScopeOptions = {
diffExitCode?: number;
draft?: boolean;
eventName?: string;
files?: Array<string | { filename: string; previous_filename?: string }>;
};
const tempDirs: string[] = [];
afterEach(() => {
cleanupTempDirs(tempDirs);
});
function readWorkflow(workflowPath: string): ScopeWorkflow {
return parse(readFileSync(workflowPath, "utf8")) as ScopeWorkflow;
}
function scopeScript(workflowPath: string): string {
const step = readWorkflow(workflowPath).jobs?.scope?.steps?.find(
(candidate) => candidate.id === "scope",
);
if (!step?.with?.script) {
throw new Error(`missing Periphery scope script in ${workflowPath}`);
}
return step.with.script;
}
async function runScope(workflowPath: string, options: ScopeOptions): Promise<string | undefined> {
const outputs = new Map<string, string>();
const context = {
eventName: options.eventName ?? "pull_request",
payload: { pull_request: { draft: options.draft ?? false, number: 123 } },
};
const exec = {
async getExecOutput(command: string, args: string[]) {
if (command !== "git" || args.slice(0, 5).join(" ") !== "diff --quiet HEAD^1 HEAD --") {
throw new Error(`unexpected scope command: ${command} ${args.join(" ")}`);
}
if (options.diffExitCode !== undefined) {
return { exitCode: options.diffExitCode };
}
const pathspecs = args.slice(5);
const filenames = (options.files ?? []).flatMap((file) =>
typeof file === "string"
? [file]
: [file.filename, file.previous_filename].filter((name): name is string => Boolean(name)),
);
const changed = filenames.some((filename) =>
pathspecs.some((pathspec) =>
pathspec.endsWith("/") ? filename.startsWith(pathspec) : filename === pathspec,
),
);
return { exitCode: changed ? 1 : 0 };
},
};
const execute = compileFunction(`return (async () => {\n${scopeScript(workflowPath)}\n})();`, [
"context",
"core",
"exec",
]) as (context: unknown, core: unknown, exec: unknown) => Promise<void>;
await execute(
context,
{ setOutput: (name: string, value: string) => outputs.set(name, value) },
exec,
);
return outputs.get("should-scan");
}
function git(repoRoot: string, args: string[]): string {
const result = spawnSync("git", args, { cwd: repoRoot, encoding: "utf8" });
if (result.status !== 0) {
throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
}
return result.stdout.trim();
}
function writeFixture(repoRoot: string, relativePath: string, contents: string): void {
const filePath = path.join(repoRoot, relativePath);
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, contents, "utf8");
}
describe("Periphery scope workflows", () => {
it.each(WORKFLOW_CASES)("uses the synthetic merge parent for $name scope", ({ path }) => {
const workflow = readWorkflow(path);
const steps = workflow.jobs?.scope?.steps ?? [];
const checkout = steps.find((step) => step.name === "Checkout");
const script = scopeScript(path);
expect(workflow.on?.pull_request?.types).toContain("converted_to_draft");
expect(workflow.on?.pull_request?.paths).toBeUndefined();
expect(checkout?.with?.["fetch-depth"]).toBe(2);
expect(steps.some((step) => step.name === "Ensure base commit")).toBe(false);
expect(script).toContain('"HEAD^1"');
expect(script).not.toContain("pulls.listFiles");
expect(() =>
compileFunction(`return (async () => {\n${script}\n})();`, ["context", "core", "exec"]),
).not.toThrow();
});
it.each(WORKFLOW_CASES)("selects only $name scope changes", async ({ path, scopedPath }) => {
await expect(runScope(path, { files: [scopedPath] })).resolves.toBe("true");
await expect(runScope(path, { files: ["docs/index.md"] })).resolves.toBe("false");
await expect(runScope(path, { draft: true, files: [scopedPath] })).resolves.toBe("false");
await expect(runScope(path, { eventName: "workflow_dispatch" })).resolves.toBe("true");
await expect(
runScope(path, {
files: [{ filename: "docs/Moved.swift", previous_filename: scopedPath }],
}),
).resolves.toBe("true");
await expect(runScope(path, { diffExitCode: 128 })).rejects.toThrow(
"git diff failed with exit code 128",
);
});
it("ignores scoped files added only by base-branch drift", async () => {
const repoRoot = makeTempRepoRoot(tempDirs, "openclaw-periphery-scope-");
git(repoRoot, ["init", "--initial-branch=main"]);
git(repoRoot, ["config", "user.name", "OpenClaw Test"]);
git(repoRoot, ["config", "user.email", "openclaw-test@example.com"]);
writeFixture(repoRoot, "docs/base.md", "base\n");
git(repoRoot, ["add", "."]);
git(repoRoot, ["commit", "-m", "base"]);
const eventBase = git(repoRoot, ["rev-parse", "HEAD"]);
git(repoRoot, ["switch", "-c", "pr"]);
writeFixture(repoRoot, "docs/pr.md", "pull request\n");
git(repoRoot, ["add", "."]);
git(repoRoot, ["commit", "-m", "pull request"]);
git(repoRoot, ["switch", "main"]);
writeFixture(
repoRoot,
"apps/shared/OpenClawKit/Sources/OpenClawKit/Main.swift",
"struct Main {}\n",
);
git(repoRoot, ["add", "."]);
git(repoRoot, ["commit", "-m", "advance main"]);
git(repoRoot, ["merge", "--no-ff", "pr", "-m", "synthetic merge"]);
const oldDiff = spawnSync(
"git",
["diff", "--quiet", eventBase, "HEAD", "--", "apps/shared/OpenClawKit/"],
{ cwd: repoRoot },
);
expect(oldDiff.status).toBe(1);
const outputs = new Map<string, string>();
const execute = compileFunction(
`return (async () => {\n${scopeScript(".github/workflows/shared-openclawkit-periphery.yml")}\n})();`,
["context", "core", "exec"],
) as (context: unknown, core: unknown, exec: unknown) => Promise<void>;
await execute(
{ eventName: "pull_request", payload: { pull_request: { draft: false, number: 123 } } },
{ setOutput: (name: string, value: string) => outputs.set(name, value) },
{
async getExecOutput(command: string, args: string[]) {
const result = spawnSync(command, args, { cwd: repoRoot });
return { exitCode: result.status ?? 128 };
},
},
);
expect(outputs.get("should-scan")).toBe("false");
expect(git(repoRoot, ["diff", "--name-only", "HEAD^1", "HEAD"])).toBe("docs/pr.md");
});
});
+36
View File
@@ -1376,6 +1376,42 @@ describe("scripts/test-projects changed-target routing", () => {
}
});
it("routes Periphery workflow edits through their scope regression tests", () => {
const workflowTargets = new Map([
[
".github/workflows/ios-periphery.yml",
[
"test/scripts/ios-periphery-comment-workflow.test.ts",
"test/scripts/periphery-scope-workflows.test.ts",
"test/scripts/ci-workflow-guards.test.ts",
],
],
[
".github/workflows/macos-periphery.yml",
[
"test/scripts/ios-periphery-comment-workflow.test.ts",
"test/scripts/periphery-scope-workflows.test.ts",
"test/scripts/ci-workflow-guards.test.ts",
],
],
[
".github/workflows/shared-openclawkit-periphery.yml",
[
"test/scripts/periphery-intersection.test.ts",
"test/scripts/periphery-scope-workflows.test.ts",
"test/scripts/ci-workflow-guards.test.ts",
],
],
]);
for (const [workflowPath, targets] of workflowTargets) {
expect(resolveChangedTestTargetPlan([workflowPath]), workflowPath).toEqual({
mode: "targets",
targets,
});
}
});
it("keeps Mantis proof workflow edits on workflow evidence regression tests", () => {
const packageAcceptanceTargets = [
"test/scripts/package-acceptance-workflow.test.ts",