fix(ci): scope PR merge diff checks to first parent (#90287)

Summary:
- This PR adds opt-in first-parent merge-head diff-base handling for CI changed-scope, changed-lanes, and OpenGrep PR scans, plus synthetic merge coverage and small lint/type cleanups.
- PR surface: Source +6, Tests +204, Config +1, Other +179. Total +390 across 15 files.
- Reproducibility: yes. The synthetic merge tests and PR body live-ref proof show the stale payload-base path can include main-only files, and first-parent mode narrows it to PR-owned paths.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(ci): update workflow guard expectations
- PR branch already contained follow-up commit before automerge: fix(ci): resolve plugin guardrail lint failures
- PR branch already contained follow-up commit before automerge: fix(ci): preserve plugin run context typing
- PR branch already contained follow-up commit before automerge: fix(ci): scope PR merge diff checks to first parent

Validation:
- ClawSweeper review passed for head 40235e8c3d.
- Required merge gates passed before the squash merge.

Prepared head SHA: 40235e8c3d
Review: https://github.com/openclaw/openclaw/pull/90287#issuecomment-4621155576

Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
This commit is contained in:
Mason Huang
2026-06-05 01:24:03 +08:00
committed by GitHub
parent fff04af46d
commit 8b29ff5f16
15 changed files with 419 additions and 29 deletions

View File

@@ -92,7 +92,7 @@ jobs:
for attempt in 1 2 3; do for attempt in 1 2 3; do
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \ timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \ -c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \ fetch --no-tags --prune --no-recurse-submodules --depth=2 origin \
"+${ref}:refs/remotes/origin/checkout" && return 0 "+${ref}:refs/remotes/origin/checkout" && return 0
fetch_status="$?" fetch_status="$?"
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
@@ -146,12 +146,12 @@ jobs:
if [ "${{ github.event_name }}" = "push" ]; then if [ "${{ github.event_name }}" = "push" ]; then
BASE="${{ github.event.before }}" BASE="${{ github.event.before }}"
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
else else
BASE="${{ github.event.pull_request.base.sha }}" BASE="${{ github.event.pull_request.base.sha }}"
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD --merge-head-first-parent
fi fi
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
- name: Build CI manifest - name: Build CI manifest
id: manifest id: manifest
env: env:

View File

@@ -44,7 +44,7 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
ref: ${{ github.sha }} ref: ${{ github.sha }}
fetch-depth: 1 fetch-depth: 2
fetch-tags: false fetch-tags: false
persist-credentials: false persist-credentials: false
submodules: false submodules: false
@@ -74,6 +74,7 @@ jobs:
- name: Run opengrep on PR diff - name: Run opengrep on PR diff
env: env:
OPENCLAW_OPENGREP_BASE_REF: ${{ github.event.pull_request.base.sha }}...HEAD OPENCLAW_OPENGREP_BASE_REF: ${{ github.event.pull_request.base.sha }}...HEAD
OPENCLAW_OPENGREP_MERGE_HEAD_FIRST_PARENT: "1"
# Findings from precise rules block this workflow. Pull requests scan # Findings from precise rules block this workflow. Pull requests scan
# changed first-party source paths only so findings stay attributable to # changed first-party source paths only so findings stay attributable to
# the PR diff. Test/fixture/QA path exclusions live in `.semgrepignore` # the PR diff. Test/fixture/QA path exclusions live in `.semgrepignore`

View File

@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs"; import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs";
import { isDirectRunUrl } from "./lib/direct-run.mjs"; import { isDirectRunUrl } from "./lib/direct-run.mjs";
import { resolveMergeHeadDiffBase } from "./lib/merge-head-diff-base.mjs";
const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024; const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024;
const IMPLAUSIBLE_NO_MERGE_BASE_DIFF_PATHS = 200; const IMPLAUSIBLE_NO_MERGE_BASE_DIFF_PATHS = 200;
@@ -213,13 +214,21 @@ export function detectChangedLanes(changedPaths, options = {}) {
} }
/** /**
* @param {{ paths: string[]; base: string; head?: string; staged?: boolean }} params * @param {{ paths: string[]; base: string; head?: string; staged?: boolean; mergeHeadFirstParent?: boolean }} params
* @returns {ChangedLaneResult} * @returns {ChangedLaneResult}
*/ */
export function detectChangedLanesForPaths(params) { export function detectChangedLanesForPaths(params) {
const base = params.staged
? params.base
: resolveMergeHeadDiffBase({
base: params.base,
head: params.head ?? "HEAD",
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
preferFirstParent: params.mergeHeadFirstParent === true,
});
const packageJsonChangeKind = params.paths.includes("package.json") const packageJsonChangeKind = params.paths.includes("package.json")
? classifyPackageJsonChangeFromGit({ ? classifyPackageJsonChangeFromGit({
base: params.base, base,
head: params.head, head: params.head,
staged: params.staged, staged: params.staged,
}) })
@@ -228,13 +237,19 @@ export function detectChangedLanesForPaths(params) {
} }
/** /**
* @param {{ base: string; head?: string; includeWorktree?: boolean; cwd?: string }} params * @param {{ base: string; head?: string; includeWorktree?: boolean; cwd?: string; mergeHeadFirstParent?: boolean }} params
* @returns {string[]} * @returns {string[]}
*/ */
export function listChangedPathsFromGit(params) { export function listChangedPathsFromGit(params) {
const base = params.base;
const head = params.head ?? "HEAD"; const head = params.head ?? "HEAD";
const cwd = params.cwd ?? process.cwd(); const cwd = params.cwd ?? process.cwd();
const base = resolveMergeHeadDiffBase({
base: params.base,
head,
cwd,
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
preferFirstParent: params.mergeHeadFirstParent === true,
});
if (!base) { if (!base) {
return []; return [];
} }
@@ -453,6 +468,7 @@ function parseArgs(argv) {
base: "origin/main", base: "origin/main",
head: "HEAD", head: "HEAD",
staged: false, staged: false,
mergeHeadFirstParent: false,
json: false, json: false,
githubOutput: false, githubOutput: false,
help: false, help: false,
@@ -465,6 +481,7 @@ function parseArgs(argv) {
stringFlag("--base", "base"), stringFlag("--base", "base"),
stringFlag("--head", "head"), stringFlag("--head", "head"),
booleanFlag("--staged", "staged"), booleanFlag("--staged", "staged"),
booleanFlag("--merge-head-first-parent", "mergeHeadFirstParent"),
booleanFlag("--json", "json"), booleanFlag("--json", "json"),
booleanFlag("--github-output", "githubOutput"), booleanFlag("--github-output", "githubOutput"),
booleanFlag("--help", "help"), booleanFlag("--help", "help"),
@@ -538,12 +555,17 @@ if (isDirectRun()) {
? args.paths ? args.paths
: args.staged : args.staged
? listStagedChangedPaths() ? listStagedChangedPaths()
: listChangedPathsFromGit({ base: args.base, head: args.head }); : listChangedPathsFromGit({
base: args.base,
head: args.head,
mergeHeadFirstParent: args.mergeHeadFirstParent,
});
const result = detectChangedLanesForPaths({ const result = detectChangedLanesForPaths({
paths, paths,
base: args.base, base: args.base,
head: args.head, head: args.head,
staged: args.staged, staged: args.staged,
mergeHeadFirstParent: args.mergeHeadFirstParent,
}); });
if (args.githubOutput) { if (args.githubOutput) {
writeChangedLaneGitHubOutput(result); writeChangedLaneGitHubOutput(result);

View File

@@ -15,7 +15,12 @@ export type InstallSmokeScope = {
export function detectChangedScope(changedPaths: string[]): ChangedScope; export function detectChangedScope(changedPaths: string[]): ChangedScope;
export function detectInstallSmokeScope(changedPaths: string[]): InstallSmokeScope; export function detectInstallSmokeScope(changedPaths: string[]): InstallSmokeScope;
export function listChangedPaths(base: string, head?: string): string[]; export function listChangedPaths(
base: string,
head?: string,
cwd?: string,
preferMergeHeadFirstParent?: boolean,
): string[];
export function writeGitHubOutput( export function writeGitHubOutput(
scope: ChangedScope, scope: ChangedScope,
outputPath?: string, outputPath?: string,

View File

@@ -1,6 +1,7 @@
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { appendFileSync } from "node:fs"; import { appendFileSync } from "node:fs";
import { isDirectRunUrl } from "./lib/direct-run.mjs"; import { isDirectRunUrl } from "./lib/direct-run.mjs";
import { resolveMergeHeadDiffBase } from "./lib/merge-head-diff-base.mjs";
/** @typedef {{ runNode: boolean; runMacos: boolean; runAndroid: boolean; runWindows: boolean; runSkillsPython: boolean; runChangedSmoke: boolean; runControlUiI18n: boolean }} ChangedScope */ /** @typedef {{ runNode: boolean; runMacos: boolean; runAndroid: boolean; runWindows: boolean; runSkillsPython: boolean; runChangedSmoke: boolean; runControlUiI18n: boolean }} ChangedScope */
/** @typedef {{ runFastOnly: boolean; runPluginContracts: boolean; runCiRouting: boolean }} NodeFastScope */ /** @typedef {{ runFastOnly: boolean; runPluginContracts: boolean; runCiRouting: boolean }} NodeFastScope */
@@ -228,13 +229,26 @@ export function detectInstallSmokeScope(changedPaths) {
/** /**
* @param {string} base * @param {string} base
* @param {string} [head] * @param {string} [head]
* @param {string} [cwd]
* @returns {string[]} * @returns {string[]}
*/ */
export function listChangedPaths(base, head = "HEAD") { export function listChangedPaths(
base,
head = "HEAD",
cwd = process.cwd(),
preferMergeHeadFirstParent = false,
) {
if (!base) { if (!base) {
return []; return [];
} }
const output = execFileSync("git", ["diff", "--name-only", base, head], { const diffBase = resolveMergeHeadDiffBase({
base,
head,
cwd,
preferFirstParent: preferMergeHeadFirstParent,
});
const output = execFileSync("git", ["diff", "--name-only", diffBase, head], {
cwd,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8", encoding: "utf8",
}); });
@@ -293,7 +307,7 @@ function isDirectRun() {
/** @param {string[]} argv */ /** @param {string[]} argv */
function parseArgs(argv) { function parseArgs(argv) {
const args = { base: "", head: "HEAD" }; const args = { base: "", head: "HEAD", mergeHeadFirstParent: false };
for (let i = 0; i < argv.length; i += 1) { for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--base") { if (argv[i] === "--base") {
args.base = argv[i + 1] ?? ""; args.base = argv[i + 1] ?? "";
@@ -303,6 +317,10 @@ function parseArgs(argv) {
if (argv[i] === "--head") { if (argv[i] === "--head") {
args.head = argv[i + 1] ?? "HEAD"; args.head = argv[i + 1] ?? "HEAD";
i += 1; i += 1;
continue;
}
if (argv[i] === "--merge-head-first-parent") {
args.mergeHeadFirstParent = true;
} }
} }
return args; return args;
@@ -311,7 +329,12 @@ function parseArgs(argv) {
if (isDirectRun()) { if (isDirectRun()) {
const args = parseArgs(process.argv.slice(2)); const args = parseArgs(process.argv.slice(2));
try { try {
const changedPaths = listChangedPaths(args.base, args.head); const changedPaths = listChangedPaths(
args.base,
args.head,
process.cwd(),
args.mergeHeadFirstParent,
);
if (changedPaths.length === 0) { if (changedPaths.length === 0) {
writeGitHubOutput(EMPTY_SCOPE); writeGitHubOutput(EMPTY_SCOPE);
process.exit(0); process.exit(0);

View File

@@ -0,0 +1,95 @@
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const DEFAULT_GIT_OUTPUT_MAX_BUFFER = 16 * 1024 * 1024;
export function resolveMergeHeadDiffBase({
base,
head = "HEAD",
cwd = process.cwd(),
maxBuffer = DEFAULT_GIT_OUTPUT_MAX_BUFFER,
preferFirstParent = false,
}) {
if (!base) {
return "";
}
if (!preferFirstParent) {
return base;
}
const parents = listCommitParents({ ref: head, cwd, maxBuffer });
if (parents.length < 2) {
return base;
}
const firstParent = resolveCommit({ ref: parents[0], cwd, maxBuffer });
const explicitBase = resolveCommit({ ref: base, cwd, maxBuffer });
if (!firstParent || firstParent === explicitBase) {
return base;
}
return firstParent;
}
function listCommitParents({ ref, cwd, maxBuffer }) {
try {
const output = execFileSync("git", ["rev-list", "--parents", "-n", "1", ref], {
cwd,
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
maxBuffer,
}).trim();
return output.split(/\s+/u).slice(1);
} catch {
return [];
}
}
function resolveCommit({ ref, cwd, maxBuffer }) {
try {
return execFileSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], {
cwd,
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
maxBuffer,
}).trim();
} catch {
return "";
}
}
function parseArgs(argv) {
const args = {
base: "",
head: "HEAD",
preferFirstParent: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--base") {
args.base = argv[index + 1] ?? "";
index += 1;
continue;
}
if (arg === "--head") {
args.head = argv[index + 1] ?? "HEAD";
index += 1;
continue;
}
if (arg === "--prefer-first-parent") {
args.preferFirstParent = true;
}
}
return args;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const args = parseArgs(process.argv.slice(2));
process.stdout.write(
`${resolveMergeHeadDiffBase({
base: args.base,
head: args.head,
preferFirstParent: args.preferFirstParent,
})}\n`,
);
}

View File

@@ -109,9 +109,43 @@ if (( CHANGED_ONLY && PATHS_PASSED )); then
exit 64 exit 64
fi fi
resolve_changed_diff_ref() {
local diff_ref="${OPENCLAW_OPENGREP_BASE_REF:-origin/main...HEAD}"
local base_ref
local head_ref
local resolved_base
if [[ "$diff_ref" != *"..."* ]]; then
printf '%s\n' "$diff_ref"
return 0
fi
if [[ "${OPENCLAW_OPENGREP_MERGE_HEAD_FIRST_PARENT:-0}" != "1" ]]; then
printf '%s\n' "$diff_ref"
return 0
fi
base_ref="${diff_ref%%...*}"
head_ref="${diff_ref#*...}"
# First-parent resolution is shared with the Node CI routers so PR diff
# scope cannot drift between changed-lanes, changed-scope, and OpenGrep.
resolved_base="$(
node "$REPO_ROOT/scripts/lib/merge-head-diff-base.mjs" \
--base "$base_ref" \
--head "$head_ref" \
--prefer-first-parent 2>/dev/null || true
)"
if [[ -z "$resolved_base" || "$resolved_base" == "$base_ref" ]]; then
printf '%s\n' "$diff_ref"
return 0
fi
printf '%s...%s\n' "$resolved_base" "$head_ref"
}
# Default scan paths match CI. Override by passing `-- <paths...>`. # Default scan paths match CI. Override by passing `-- <paths...>`.
if (( PATHS_PASSED == 0 )); then if (( PATHS_PASSED == 0 )); then
if (( CHANGED_ONLY )); then if (( CHANGED_ONLY )); then
CHANGED_DIFF_REF="$(resolve_changed_diff_ref)"
SCAN_PATHS=() SCAN_PATHS=()
while IFS= read -r path; do while IFS= read -r path; do
# OpenGrep errors when an explicit changed path is a symlink; scan the # OpenGrep errors when an explicit changed path is a symlink; scan the
@@ -125,7 +159,7 @@ if (( PATHS_PASSED == 0 )); then
SCAN_PATHS+=( "$path" ) SCAN_PATHS+=( "$path" )
done < <( done < <(
{ {
git diff --name-only --diff-filter=ACMRTUXB "${OPENCLAW_OPENGREP_BASE_REF:-origin/main...HEAD}" 2>/dev/null || true git diff --name-only --diff-filter=ACMRTUXB "$CHANGED_DIFF_REF" 2>/dev/null || true
git diff --name-only --diff-filter=ACMRTUXB -- 2>/dev/null || true git diff --name-only --diff-filter=ACMRTUXB -- 2>/dev/null || true
git ls-files --others --exclude-standard git ls-files --others --exclude-standard
} | awk '/^(src|extensions|apps|packages|scripts)\// { print }' | sort -u } | awk '/^(src|extensions|apps|packages|scripts)\// { print }' | sort -u
@@ -135,7 +169,7 @@ if (( PATHS_PASSED == 0 )); then
RULEPACK_CHANGED_PATHS+=( "$path" ) RULEPACK_CHANGED_PATHS+=( "$path" )
done < <( done < <(
{ {
git diff --name-only --diff-filter=ACMRTUXB "${OPENCLAW_OPENGREP_BASE_REF:-origin/main...HEAD}" 2>/dev/null || true git diff --name-only --diff-filter=ACMRTUXB "$CHANGED_DIFF_REF" 2>/dev/null || true
git diff --name-only --diff-filter=ACMRTUXB -- 2>/dev/null || true git diff --name-only --diff-filter=ACMRTUXB -- 2>/dev/null || true
git ls-files --others --exclude-standard git ls-files --others --exclude-standard
} | awk '/^(security\/opengrep\/|scripts\/run-opengrep\.sh$|\.semgrepignore$|\.github\/workflows\/opengrep-)/ { print }' | sort -u } | awk '/^(security\/opengrep\/|scripts\/run-opengrep\.sh$|\.semgrepignore$|\.github\/workflows\/opengrep-)/ { print }' | sort -u

View File

@@ -256,5 +256,5 @@ export function promptMigrationSkillSelectionValues(
return prompt.prompt(); return prompt.prompt();
} }
/** Back-compat alias for plugin selection prompts that share the same picker. */ /** Compatibility alias for plugin selection prompts that share the same picker. */
export const promptMigrationSelectionValues = promptMigrationSkillSelectionValues; export const promptMigrationSelectionValues = promptMigrationSkillSelectionValues;

View File

@@ -515,8 +515,10 @@ function collectDeprecatedTestBarrelImports(): string[] {
function collectDeprecatedPackageTestingBridgeDrift(): string[] { function collectDeprecatedPackageTestingBridgeDrift(): string[] {
const source = fs const source = fs
.readFileSync(resolve(REPO_ROOT, "packages/plugin-sdk/src/testing.ts"), "utf8") .readFileSync(resolve(REPO_ROOT, "packages/plugin-sdk/src/testing.ts"), "utf8")
.trim(); .split("\n")
return source === 'export * from "../../../src/plugin-sdk/testing.js";' .map((line) => line.trim())
.filter((line) => line && !line.startsWith("//"));
return source.length === 1 && source[0] === 'export * from "../../../src/plugin-sdk/testing.js";'
? [] ? []
: ["packages/plugin-sdk/src/testing.ts"]; : ["packages/plugin-sdk/src/testing.ts"];
} }

View File

@@ -6,6 +6,7 @@ import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import { withPluginHostCleanupTimeout } from "./host-hook-cleanup-timeout.js"; import { withPluginHostCleanupTimeout } from "./host-hook-cleanup-timeout.js";
import { import {
isPluginJsonValue, isPluginJsonValue,
type PluginAgentEventSubscriptionRegistration,
type PluginHostCleanupReason, type PluginHostCleanupReason,
type PluginJsonValue, type PluginJsonValue,
type PluginRunContextGetParams, type PluginRunContextGetParams,
@@ -17,6 +18,9 @@ import type { PluginRegistry } from "./registry-types.js";
type PluginRunContextNamespaces = Map<string, PluginJsonValue>; type PluginRunContextNamespaces = Map<string, PluginJsonValue>;
type PluginRunContextByPlugin = Map<string, PluginRunContextNamespaces>; type PluginRunContextByPlugin = Map<string, PluginRunContextNamespaces>;
type PluginAgentEventSubscriptionContext = Parameters<
PluginAgentEventSubscriptionRegistration["handle"]
>[1];
type SchedulerJobRecord = { type SchedulerJobRecord = {
pluginId: string; pluginId: string;
@@ -305,10 +309,12 @@ export function dispatchPluginAgentEventSubscriptions(params: {
const pluginId = registration.pluginId; const pluginId = registration.pluginId;
const runId = params.event.runId; const runId = params.event.runId;
let handlerActive = true; let handlerActive = true;
const ctx = { const ctx: PluginAgentEventSubscriptionContext = {
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Run-context JSON reads are caller-typed by namespace. getRunContext: ((namespace: string) =>
getRunContext: <T extends PluginJsonValue = PluginJsonValue>(namespace: string) => getPluginRunContext({
getPluginRunContext({ pluginId, get: { runId, namespace } }) as T | undefined, pluginId,
get: { runId, namespace },
})) as PluginAgentEventSubscriptionContext["getRunContext"],
setRunContext: (namespace: string, value: PluginJsonValue) => { setRunContext: (namespace: string, value: PluginJsonValue) => {
setPluginRunContext({ setPluginRunContext({
pluginId, pluginId,

View File

@@ -25,7 +25,12 @@ const { detectChangedScope, detectInstallSmokeScope, detectNodeFastScope, listCh
runPluginContracts: boolean; runPluginContracts: boolean;
runCiRouting: boolean; runCiRouting: boolean;
}; };
listChangedPaths: (base: string, head?: string) => string[]; listChangedPaths: (
base: string,
head?: string,
cwd?: string,
preferMergeHeadFirstParent?: boolean,
) => string[];
}; };
const markerPaths: string[] = []; const markerPaths: string[] = [];
@@ -56,6 +61,42 @@ function parseGitHubOutput(output: string): Record<string, string> {
return parsed; return parsed;
} }
function git(repoDir: string, args: string[]): string {
return execFileSync("git", args, { cwd: repoDir, encoding: "utf8" }).trim();
}
function writeRepoFile(repoDir: string, filePath: string, contents: string): void {
const absolutePath = path.join(repoDir, filePath);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, contents, "utf8");
}
function createSyntheticMergeRepo(prefix: string): { repoDir: string; staleBase: string } {
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(repoDir);
git(repoDir, ["init", "-b", "main"]);
git(repoDir, ["config", "user.email", "ci@example.invalid"]);
git(repoDir, ["config", "user.name", "CI"]);
writeRepoFile(repoDir, "README.md", "base\n");
git(repoDir, ["add", "."]);
git(repoDir, ["commit", "-m", "base"]);
const staleBase = git(repoDir, ["rev-parse", "HEAD"]);
git(repoDir, ["switch", "-c", "feature"]);
writeRepoFile(repoDir, "src/pr.ts", "export const pr = true;\n");
git(repoDir, ["add", "."]);
git(repoDir, ["commit", "-m", "feature"]);
git(repoDir, ["switch", "main"]);
writeRepoFile(repoDir, "src/main-only.ts", "export const mainOnly = true;\n");
git(repoDir, ["add", "."]);
git(repoDir, ["commit", "-m", "main only"]);
git(repoDir, ["merge", "--no-ff", "feature", "-m", "synthetic merge"]);
return { repoDir, staleBase };
}
describe("detectChangedScope", () => { describe("detectChangedScope", () => {
it("fails safe when no paths are provided", () => { it("fails safe when no paths are provided", () => {
expect(detectChangedScope([])).toEqual({ expect(detectChangedScope([])).toEqual({
@@ -652,6 +693,22 @@ describe("detectChangedScope", () => {
expect(fs.existsSync(markerPath)).toBe(false); expect(fs.existsSync(markerPath)).toBe(false);
}); });
it("uses the merge commit first parent instead of a stale PR payload base", () => {
const { repoDir, staleBase } = createSyntheticMergeRepo("openclaw-ci-scope-merge-");
expect(
execFileSync("git", ["diff", "--name-only", staleBase, "HEAD"], {
cwd: repoDir,
encoding: "utf8",
})
.trim()
.split("\n")
.toSorted(),
).toEqual(["src/main-only.ts", "src/pr.ts"]);
expect(listChangedPaths(staleBase, "HEAD", repoDir, true)).toEqual(["src/pr.ts"]);
});
it("keeps direct CLI preflight empty diffs as no-op scope", () => { it("keeps direct CLI preflight empty diffs as no-op scope", () => {
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ci-scope-empty-")); const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ci-scope-empty-"));
tempDirs.push(repoDir); tempDirs.push(repoDir);

View File

@@ -71,6 +71,71 @@ function parseChangedLaneOutput(output: string): {
}; };
} }
function writeRepoFile(repoDir: string, filePath: string, contents: string): void {
const absolutePath = path.join(repoDir, filePath);
mkdirSync(path.dirname(absolutePath), { recursive: true });
writeFileSync(absolutePath, contents, "utf8");
}
function createSyntheticMergeRepo(prefix: string): { dir: string; staleBase: string } {
const dir = makeTempRepoRoot(tempDirs, prefix);
git(dir, ["init", "-q", "--initial-branch=main"]);
writeRepoFile(dir, "README.md", "base\n");
git(dir, ["add", "."]);
git(dir, [
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test User",
"commit",
"-q",
"-m",
"base",
]);
const staleBase = git(dir, ["rev-parse", "HEAD"]);
git(dir, ["switch", "-q", "-c", "feature"]);
writeRepoFile(dir, "src/pr.ts", "export const pr = true;\n");
git(dir, ["add", "."]);
git(dir, [
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test User",
"commit",
"-q",
"-m",
"feature",
]);
git(dir, ["switch", "-q", "main"]);
writeRepoFile(dir, "src/main-only.ts", "export const mainOnly = true;\n");
git(dir, ["add", "."]);
git(dir, [
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test User",
"commit",
"-q",
"-m",
"main only",
]);
git(dir, [
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test User",
"merge",
"--no-ff",
"feature",
"-m",
"synthetic merge",
]);
return { dir, staleBase };
}
afterEach(() => { afterEach(() => {
cleanupCorepackPnpmShimDir(); cleanupCorepackPnpmShimDir();
cleanupTempDirs(tempDirs); cleanupTempDirs(tempDirs);
@@ -251,6 +316,23 @@ describe("scripts/changed-lanes", () => {
} }
}); });
it("uses the merge commit first parent instead of a stale PR payload base", () => {
const { dir, staleBase } = createSyntheticMergeRepo("openclaw-changed-lanes-merge-");
expect(listChangedPathsFromGit({ base: staleBase, cwd: dir, includeWorktree: false })).toEqual([
"src/main-only.ts",
"src/pr.ts",
]);
expect(
listChangedPathsFromGit({
base: staleBase,
cwd: dir,
includeWorktree: false,
mergeHeadFirstParent: true,
}),
).toEqual(["src/pr.ts"]);
});
it("ignores local Crabbox metadata in the default local diff", () => { it("ignores local Crabbox metadata in the default local diff", () => {
const dir = makeTempRepoRoot(tempDirs, "openclaw-changed-lanes-crabbox-"); const dir = makeTempRepoRoot(tempDirs, "openclaw-changed-lanes-crabbox-");
git(dir, ["init", "-q", "--initial-branch=main"]); git(dir, ["init", "-q", "--initial-branch=main"]);

View File

@@ -59,8 +59,9 @@ describe("ci workflow guards", () => {
expect(checkoutStep.run, jobName).toContain("timed out on attempt $attempt; retrying"); expect(checkoutStep.run, jobName).toContain("timed out on attempt $attempt; retrying");
expect(checkoutStep.run, jobName).not.toContain("if timeout --signal=TERM"); expect(checkoutStep.run, jobName).not.toContain("if timeout --signal=TERM");
expect(checkoutStep.run, jobName).toContain("-c protocol.version=2"); expect(checkoutStep.run, jobName).toContain("-c protocol.version=2");
const expectedDepth = jobName === "preflight" ? 2 : 1;
expect(checkoutStep.run, jobName).toContain( expect(checkoutStep.run, jobName).toContain(
"fetch --no-tags --prune --no-recurse-submodules --depth=1 origin", `fetch --no-tags --prune --no-recurse-submodules --depth=${expectedDepth} origin`,
); );
if (jobName !== "skills-python") { if (jobName !== "skills-python") {
expect(checkoutStep.run, jobName).toContain('if [ "$fetch_status" = "124" ]'); expect(checkoutStep.run, jobName).toContain('if [ "$fetch_status" = "124" ]');

View File

@@ -214,7 +214,6 @@ describe("production lint suppressions", () => {
"src/plugin-sdk/test-helpers/public-surface-loader.ts|typescript/no-unnecessary-type-parameters|1", "src/plugin-sdk/test-helpers/public-surface-loader.ts|typescript/no-unnecessary-type-parameters|1",
"src/plugin-sdk/test-helpers/subagent-hooks.ts|typescript/no-unnecessary-type-parameters|1", "src/plugin-sdk/test-helpers/subagent-hooks.ts|typescript/no-unnecessary-type-parameters|1",
"src/plugins/hooks.ts|typescript/no-unnecessary-type-parameters|1", "src/plugins/hooks.ts|typescript/no-unnecessary-type-parameters|1",
"src/plugins/host-hook-runtime.ts|typescript/no-unnecessary-type-parameters|1",
"src/plugins/host-hook-state.ts|typescript/no-unnecessary-type-parameters|1", "src/plugins/host-hook-state.ts|typescript/no-unnecessary-type-parameters|1",
"src/plugins/host-hooks.ts|typescript/no-unnecessary-type-parameters|1", "src/plugins/host-hooks.ts|typescript/no-unnecessary-type-parameters|1",
"src/plugins/lazy-service-module.ts|typescript/no-unnecessary-type-parameters|1", "src/plugins/lazy-service-module.ts|typescript/no-unnecessary-type-parameters|1",

View File

@@ -15,6 +15,17 @@ function writeFile(filePath: string, content: string): void {
fs.writeFileSync(filePath, content); fs.writeFileSync(filePath, content);
} }
function copyRunOpengrepFiles(repo: string): void {
const scriptSource = path.resolve("scripts/run-opengrep.sh");
const helperSource = path.resolve("scripts/lib/merge-head-diff-base.mjs");
writeFile(path.join(repo, "scripts/run-opengrep.sh"), fs.readFileSync(scriptSource, "utf8"));
writeFile(
path.join(repo, "scripts/lib/merge-head-diff-base.mjs"),
fs.readFileSync(helperSource, "utf8"),
);
fs.chmodSync(path.join(repo, "scripts/run-opengrep.sh"), 0o755);
}
describe("run-opengrep.sh", () => { describe("run-opengrep.sh", () => {
it("validates the rulepack when only OpenGrep rulepack files changed", () => { it("validates the rulepack when only OpenGrep rulepack files changed", () => {
const repo = createTempDir("openclaw-run-opengrep-"); const repo = createTempDir("openclaw-run-opengrep-");
@@ -22,9 +33,7 @@ describe("run-opengrep.sh", () => {
git(repo, "config", "user.email", "test@example.com"); git(repo, "config", "user.email", "test@example.com");
git(repo, "config", "user.name", "Test User"); git(repo, "config", "user.name", "Test User");
const scriptSource = path.resolve("scripts/run-opengrep.sh"); copyRunOpengrepFiles(repo);
writeFile(path.join(repo, "scripts/run-opengrep.sh"), fs.readFileSync(scriptSource, "utf8"));
fs.chmodSync(path.join(repo, "scripts/run-opengrep.sh"), 0o755);
writeFile(path.join(repo, "security/opengrep/precise.yml"), "rules: []\n"); writeFile(path.join(repo, "security/opengrep/precise.yml"), "rules: []\n");
git(repo, "add", "."); git(repo, "add", ".");
git(repo, "commit", "-qm", "initial"); git(repo, "commit", "-qm", "initial");
@@ -57,4 +66,58 @@ describe("run-opengrep.sh", () => {
const args = fs.readFileSync(path.join(repo, "opengrep-args.txt"), "utf8"); const args = fs.readFileSync(path.join(repo, "opengrep-args.txt"), "utf8");
expect(args).toContain("security/opengrep/precise.yml"); expect(args).toContain("security/opengrep/precise.yml");
}); });
it("scans PR files instead of main-only files when the payload base is stale", () => {
const repo = createTempDir("openclaw-run-opengrep-merge-");
git(repo, "init", "-q", "--initial-branch=main");
git(repo, "config", "user.email", "test@example.com");
git(repo, "config", "user.name", "Test User");
copyRunOpengrepFiles(repo);
writeFile(path.join(repo, "security/opengrep/precise.yml"), "rules: []\n");
writeFile(path.join(repo, "README.md"), "base\n");
git(repo, "add", ".");
git(repo, "commit", "-qm", "base");
const staleBase = git(repo, "rev-parse", "HEAD");
git(repo, "switch", "-q", "-c", "feature");
writeFile(path.join(repo, "src/pr.ts"), "export const pr = true;\n");
git(repo, "add", ".");
git(repo, "commit", "-qm", "feature");
git(repo, "switch", "-q", "main");
writeFile(path.join(repo, "src/main-only.ts"), "export const mainOnly = true;\n");
git(repo, "add", ".");
git(repo, "commit", "-qm", "main only");
git(repo, "merge", "--no-ff", "feature", "-m", "synthetic merge");
const argsPath = path.join(repo, "opengrep-args.txt");
const binDir = path.join(repo, "bin");
fs.mkdirSync(binDir);
writeFile(
path.join(binDir, "opengrep"),
[
"#!/usr/bin/env bash",
`printf '%s\\n' "$@" > ${JSON.stringify(argsPath)}`,
"exit 0",
"",
].join("\n"),
);
fs.chmodSync(path.join(binDir, "opengrep"), 0o755);
execFileSync("bash", ["scripts/run-opengrep.sh", "--changed"], {
cwd: repo,
env: {
...process.env,
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
OPENCLAW_OPENGREP_BASE_REF: `${staleBase}...HEAD`,
OPENCLAW_OPENGREP_MERGE_HEAD_FIRST_PARENT: "1",
},
encoding: "utf8",
});
const args = fs.readFileSync(argsPath, "utf8");
expect(args).toContain("src/pr.ts");
expect(args).not.toContain("src/main-only.ts");
});
}); });