ci: guard dependency graph PR changes (#87791)

This commit is contained in:
Dallin Romney
2026-05-28 18:13:54 -07:00
committed by GitHub
parent efc93bf282
commit c8f2bbf76d
5 changed files with 966 additions and 187 deletions

View File

@@ -0,0 +1,279 @@
import { describe, expect, it } from "vitest";
import {
dependencyGuardCommentHeadSha,
dependencyFieldChanges,
dependencyOverrideExpectedSha,
findDependencyOverrideCommand,
findDependencyOverrideCommandAsync,
isDependencyGuardAuthorizedForHead,
isDependencyFile,
isDependencyManifest,
isPackageLockfile,
renderAuthorizedDependencyComment,
renderBlockedDependencyComment,
renderClearedDependencyGuardComment,
sanitizeDisplayValue,
securityApproverSet,
} from "../../scripts/github/dependency-change-awareness.mjs";
const headSha = "a".repeat(40);
const staleSha = "b".repeat(40);
describe("dependency change awareness script", () => {
it("detects dependency awareness file surfaces", () => {
expect(isDependencyFile("pnpm-lock.yaml")).toBe(true);
expect(isDependencyFile("package.json")).toBe(false);
expect(isDependencyFile("ui/package.json")).toBe(false);
expect(isDependencyFile("packages/core/package.json")).toBe(false);
expect(isDependencyFile("qa/convex-credential-broker/package.json")).toBe(false);
expect(isDependencyFile("extensions/slack/npm-shrinkwrap.json")).toBe(true);
expect(isDependencyFile("tools/nested/pnpm-lock.yaml")).toBe(true);
expect(isDependencyFile("src/index.ts")).toBe(false);
expect(isPackageLockfile("pnpm-lock.yaml")).toBe(true);
expect(isPackageLockfile("extensions/slack/npm-shrinkwrap.json")).toBe(true);
expect(isPackageLockfile("package.json")).toBe(false);
});
it("compares package manifest fields that can affect dependency resolution", () => {
expect(isDependencyManifest("package.json")).toBe(true);
expect(isDependencyManifest("extensions/slack/package.json")).toBe(true);
expect(isDependencyManifest("qa/convex-credential-broker/package.json")).toBe(true);
expect(isDependencyManifest("src/index.ts")).toBe(false);
expect(
dependencyFieldChanges(
{ scripts: { test: "old" }, dependencies: { a: "1" } },
{ scripts: { test: "new" }, dependencies: { a: "1" } },
),
).toEqual([]);
expect(
dependencyFieldChanges(
{ dependencies: { a: "1" }, devDependencies: { b: "1" } },
{ dependencies: { a: "2" }, devDependencies: { b: "1", c: "1" } },
),
).toEqual(["dependencies", "devDependencies"]);
expect(
dependencyFieldChanges(
{
optionalDependencies: { a: "1" },
peerDependencies: { b: "1" },
overrides: { c: "1" },
packageManager: "pnpm@10.0.0",
pnpm: { patchedDependencies: { d: "patches/d.patch" } },
scripts: { test: "old" },
},
{
optionalDependencies: { a: "2" },
peerDependencies: { b: "2" },
overrides: { c: "2" },
packageManager: "pnpm@10.1.0",
pnpm: { patchedDependencies: { d: "patches/d2.patch" } },
scripts: { test: "new" },
},
),
).toEqual([
"optionalDependencies",
"peerDependencies",
"overrides",
"packageManager",
"pnpm",
]);
});
it("accepts only security-member override commands for the current head sha", () => {
const comments = [
{
body: "/allow-dependencies-change not enough",
created_at: "2026-05-28T20:00:00Z",
user: { login: "not-security" },
},
{
body: "/allow-dependencies-change stale approval",
created_at: "2026-05-28T20:01:00Z",
user: { login: "security-user" },
},
{
body: "/allow-dependencies-change reviewed dependency graph",
created_at: "2026-05-28T20:03:00Z",
html_url: "https://example.test/comment",
user: { login: "security-user" },
},
];
const override = findDependencyOverrideCommand({
comments,
expectedSha: headSha,
isSecurityMember: (login) => login === "security-user",
newerThan: "2026-05-28T20:02:00Z",
});
expect(override).toEqual({
login: "security-user",
reason: "reviewed dependency graph",
sha: headSha,
url: "https://example.test/comment",
});
});
it("rejects stale or non-security override commands", async () => {
const comments = [
{
body: "/allow-dependencies-change stale approval",
created_at: "2026-05-28T20:00:00Z",
user: { login: "security-user" },
},
{
body: "/allow-dependencies-change not enough",
created_at: "2026-05-28T20:02:00Z",
user: { login: "not-security" },
},
];
await expect(
findDependencyOverrideCommandAsync({
comments,
expectedSha: headSha,
isSecurityMember: async (login) => login === "security-user",
newerThan: "2026-05-28T20:01:00Z",
}),
).resolves.toBeNull();
});
it("rejects override commands without a freshness barrier", () => {
const override = findDependencyOverrideCommand({
comments: [
{
body: "/allow-dependencies-change",
created_at: "2026-05-28T20:03:00Z",
user: { login: "security-user" },
},
],
expectedSha: headSha,
isSecurityMember: (login) => login === "security-user",
});
expect(override).toBeNull();
});
it("accepts override commands without a reason", () => {
const override = findDependencyOverrideCommand({
comments: [
{
body: "/allow-dependencies-change",
created_at: "2026-05-28T20:03:00Z",
user: { login: "security-user" },
},
],
expectedSha: headSha,
isSecurityMember: (login) => login === "security-user",
newerThan: "2026-05-28T20:02:00Z",
});
expect(override).toEqual({
login: "security-user",
reason: null,
sha: headSha,
url: undefined,
});
});
it("binds override commands to the head sha in the blocked guard comment", () => {
const blockedComment = {
body: renderBlockedDependencyComment({
baseBranch: "main",
headSha,
lockfileChanges: ["pnpm-lock.yaml"],
dependencyManifestChanges: [],
}),
};
const staleBlockedComment = {
body: renderBlockedDependencyComment({
baseBranch: "main",
headSha: staleSha,
lockfileChanges: ["pnpm-lock.yaml"],
dependencyManifestChanges: [],
}),
};
expect(dependencyGuardCommentHeadSha(blockedComment)).toBe(headSha);
expect(dependencyOverrideExpectedSha(blockedComment, headSha)).toBe(headSha);
expect(dependencyOverrideExpectedSha(staleBlockedComment, headSha)).toBeNull();
});
it("preserves same-head authorization across reruns", () => {
const authorizedComment = {
body: renderAuthorizedDependencyComment({
login: "security-user",
reason: null,
sha: headSha,
}),
};
expect(dependencyGuardCommentHeadSha(authorizedComment)).toBe(headSha);
expect(isDependencyGuardAuthorizedForHead(authorizedComment, headSha)).toBe(true);
expect(isDependencyGuardAuthorizedForHead(authorizedComment, staleSha)).toBe(false);
expect(dependencyOverrideExpectedSha(authorizedComment, headSha)).toBeNull();
});
it("renders deterministic removal guidance for blocked lockfile changes", () => {
const body = renderBlockedDependencyComment({
baseBranch: "main",
headSha,
lockfileChanges: ["pnpm-lock.yaml", "extensions/slack/npm-shrinkwrap.json"],
dependencyManifestChanges: [
{
path: "package.json",
fields: ["dependencies"],
},
],
});
expect(body).toContain("<!-- openclaw:dependency-graph-guard -->");
expect(body).toContain("Dependency graph changes are blocked");
expect(body).toContain("`pnpm-lock.yaml` changed.");
expect(body).toContain("`extensions/slack/npm-shrinkwrap.json` changed.");
expect(body).toContain("`package.json` changed `dependencies`.");
expect(body).toContain(
"git checkout 'origin/main' -- 'pnpm-lock.yaml' 'extensions/slack/npm-shrinkwrap.json'",
);
expect(body).toContain("/allow-dependencies-change");
expect(body).toContain(`current head SHA (\`${headSha}\`)`);
expect(body).toContain("A later push requires a fresh approval.");
});
it("shell-quotes PR-controlled paths in removal guidance", () => {
const body = renderBlockedDependencyComment({
baseBranch: "release/canary branch",
headSha,
lockfileChanges: [
"dir with spaces/pnpm-lock.yaml",
"safe/quote'$(touch bad);/package-lock.json",
],
dependencyManifestChanges: [],
});
expect(body).toContain(
"git checkout 'origin/release/canary branch' -- 'dir with spaces/pnpm-lock.yaml' 'safe/quote'\\''$(touch bad);/package-lock.json'",
);
});
it("renders a cleared guard comment that preserves approval freshness", () => {
const body = renderClearedDependencyGuardComment({ headSha });
expect(body).toContain("<!-- openclaw:dependency-graph-guard -->");
expect(body).toContain("Dependency graph guard cleared");
expect(body).toContain(headSha);
expect(body).toContain("requires a fresh `/allow-dependencies-change` comment");
});
it("parses explicit security approver allowlists", () => {
expect(securityApproverSet("vincentkoc, steipete\njoshavant")).toEqual(
new Set(["vincentkoc", "steipete", "joshavant"]),
);
});
it("sanitizes display values", () => {
expect(sanitizeDisplayValue("abc\u0000def")).toBe("abc?def");
expect(sanitizeDisplayValue("x".repeat(300))).toHaveLength(240);
});
});

View File

@@ -31,23 +31,22 @@ describe("dependency change awareness workflow", () => {
const parsed = readWorkflow();
expect(workflow).toContain("pull_request_target:");
expect(workflow).toContain("metadata-only workflow; no checkout or untrusted code execution");
expect(workflow).toContain("checks trusted base script only; never checks out PR head");
expect(parsed.permissions).toEqual({
contents: "read",
"pull-requests": "write",
issues: "write",
});
});
it("does not checkout or execute PR-controlled code", () => {
it("checks out only trusted base scripts and does not execute PR-controlled code", () => {
const workflow = readFileSync(WORKFLOW, "utf8");
const forbiddenSnippets = [
"actions/checkout",
"github.event.pull_request.head",
"pullRequest.head",
"pnpm install",
"npm install",
"pnpm dlx",
"contents: write",
"actions: write",
"id-token: write",
"secrets.",
@@ -59,44 +58,53 @@ describe("dependency change awareness workflow", () => {
}
const steps = readWorkflow().jobs?.["dependency-change-awareness"]?.steps ?? [];
expect(steps).toHaveLength(1);
expect(steps[0].run).toBeUndefined();
expect(steps).toHaveLength(2);
expect(steps[0].uses).toBe("actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd");
expect(steps[0].with?.ref).toBe("${{ github.event.pull_request.base.sha }}");
expect(steps[0].with?.["persist-credentials"]).toBe(false);
expect(steps[1].run).toBe("node scripts/github/dependency-change-awareness.mjs");
});
it("uses a pinned GitHub Script action and bounded sticky comments", () => {
it("uses a dedicated checked-in script and bounded sticky comments", () => {
const workflow = readFileSync(WORKFLOW, "utf8");
const steps = readWorkflow().jobs?.["dependency-change-awareness"]?.steps ?? [];
const step = steps[0];
const runStep = steps[1];
const script = readFileSync("scripts/github/dependency-change-awareness.mjs", "utf8");
expect(step.uses).toBe("actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3");
expect(step.with?.script).toContain("<!-- openclaw:dependency-change-awareness -->");
expect(step.with?.script).toContain("const maxListedFiles = 25;");
expect(step.with?.script).toContain("const sanitizeDisplayValue = (value)");
expect(step.with?.script).toContain('.replace(/[\\u0000-\\u001f\\u007f]/gu, "?")');
expect(step.with?.script).toContain(".slice(0, 240)");
expect(step.with?.script).toContain('comment.user?.login === "github-actions[bot]"');
expect(step.with?.script).toContain("github.rest.pulls.listFiles");
expect(step.with?.script).toContain("github.rest.issues.createComment");
expect(step.with?.script).toContain("github.rest.issues.updateComment");
expect(step.with?.script).toContain("github.rest.issues.deleteComment");
expect(step.with?.script).toContain("ignoreUnavailableWritePermission");
expect(step.with?.script).toContain("error?.status === 403");
expect(workflow).toContain('"dependencies-changed"');
expect(runStep.env?.OPENCLAW_SECURITY_TEAM_SLUG).toBe("openclaw-secops");
expect(runStep.env?.OPENCLAW_SECURITY_APPROVERS).toBe("vincentkoc,steipete,joshavant");
expect(workflow).toContain("scripts/github/dependency-change-awareness.mjs");
expect(script).toContain('"dependencies-changed"');
expect(script).not.toContain('"blocked: dependencies"');
});
it("detects the intended dependency-related file surfaces", () => {
const script = readWorkflow().jobs?.["dependency-change-awareness"]?.steps?.[0].with?.script;
expect(script).toContain('filename === "package.json"');
expect(script).toContain('filename === "package-lock.json"');
expect(script).toContain('filename === "npm-shrinkwrap.json"');
expect(script).toContain('filename === "pnpm-lock.yaml"');
const script = readFileSync("scripts/github/dependency-change-awareness.mjs", "utf8");
expect(script).toContain('filename.endsWith("package.json")');
expect(script).toContain('filename.endsWith("package-lock.json")');
expect(script).toContain('filename.endsWith("npm-shrinkwrap.json")');
expect(script).toContain('filename.endsWith("pnpm-lock.yaml")');
expect(script).toContain('filename === "pnpm-workspace.yaml"');
expect(script).toContain('filename === "ui/package.json"');
expect(script).toContain('filename.startsWith("patches/")');
expect(script).toContain("^packages\\/[^/]+\\/package\\.json$");
expect(script).toContain("^extensions\\/[^/]+\\/package-lock\\.json$");
expect(script).toContain("^extensions\\/[^/]+\\/npm-shrinkwrap\\.json$");
expect(script).toContain("^extensions\\/[^/]+\\/package\\.json$");
expect(script).toContain("dependencyGraphFiles");
});
it("blocks package lockfile and manifest graph changes unless secops approves the current head sha", () => {
const script = readFileSync("scripts/github/dependency-change-awareness.mjs", "utf8");
expect(script).toContain('filename.endsWith("pnpm-lock.yaml")');
expect(script).toContain('filename.endsWith("package-lock.json")');
expect(script).toContain('filename.endsWith("npm-shrinkwrap.json")');
expect(script).toContain('"optionalDependencies"');
expect(script).toContain('"peerDependencies"');
expect(script).toContain('"overrides"');
expect(script).toContain('"packageManager"');
expect(script).toContain("/allow-dependencies-change");
expect(script).toContain("openclaw-secops");
expect(script).toContain("securityApproverSet");
expect(script).toContain("/memberships/");
expect(script).toContain("isCommentNewerThan");
expect(script).toContain("A later push requires a fresh approval.");
expect(script).toContain("process.exitCode = 1");
});
it("requires secops review for future workflow or guard changes", () => {
@@ -107,6 +115,9 @@ describe("dependency change awareness workflow", () => {
expect(codeowners).toContain(
"/test/scripts/dependency-change-awareness-workflow.test.ts @openclaw/openclaw-secops",
);
expect(codeowners).toContain(
"/scripts/github/dependency-change-awareness.mjs @openclaw/openclaw-secops",
);
expect(codeowners).toContain("/package-lock.json @openclaw/openclaw-secops");
expect(codeowners).toContain("/npm-shrinkwrap.json @openclaw/openclaw-secops");
expect(codeowners).toContain("/extensions/*/package-lock.json @openclaw/openclaw-secops");