diff --git a/scripts/github/dependency-guard.d.mts b/scripts/github/dependency-guard.d.mts index 7d9ce8f4774a..5cdb10aa8a52 100644 --- a/scripts/github/dependency-guard.d.mts +++ b/scripts/github/dependency-guard.d.mts @@ -17,6 +17,11 @@ type PullRequest = { }; type ActorCandidate = { login: string; source: string }; +type DependencyGraphChange = { + change_type: string; + manifest?: string | null; + name?: string | null; +}; export function isDependencyFile(filename: string): boolean; export function isDependencyManifest(filename: string): boolean; @@ -25,6 +30,9 @@ export function dependencyFieldChanges( baseManifest: Record, headManifest: Record, ): string[]; +export function isRemovalOnlyDependencyGraphChange( + changes: readonly DependencyGraphChange[], +): boolean; export function shouldAutoscrubDependencyLockfiles(options: { dependencyFiles?: string[]; lockfileChanges: unknown[]; @@ -76,6 +84,10 @@ export function renderTrustedDependencyComment(options: { actor: { login: string; reason: string }; headSha: string; }): string; +export function renderRemovalOnlyDependencyComment(options: { + dependencyGraphChanges: readonly DependencyGraphChange[]; + headSha?: string; +}): string; export function renderAutoscrubbedDependencyComment(options: { baseBranch: string; lockfileChanges: string[]; diff --git a/scripts/github/dependency-guard.mjs b/scripts/github/dependency-guard.mjs index 9944569ca60e..de1ec7e4ccb4 100644 --- a/scripts/github/dependency-guard.mjs +++ b/scripts/github/dependency-guard.mjs @@ -82,6 +82,10 @@ export function dependencyFieldChanges(baseManifest, headManifest) { return changes; } +export function isRemovalOnlyDependencyGraphChange(changes) { + return changes.length > 0 && changes.every((change) => change.change_type === "removed"); +} + export function shouldAutoscrubDependencyLockfiles({ dependencyFiles = [], lockfileChanges, @@ -308,6 +312,26 @@ export function renderTrustedDependencyComment({ actor, headSha }) { ].join("\n"); } +export function renderRemovalOnlyDependencyComment({ dependencyGraphChanges, headSha }) { + const removalLines = dependencyGraphChanges.map( + (change) => + `- Removed ${markdownCode(change.name ?? "")} from ${markdownCode(change.manifest ?? "")}.`, + ); + return [ + dependencyGraphGuardMarker, + "", + "### Dependency removals noted", + "", + "This PR only removes dependencies from the dependency graph, so the dependency guard is informational and does not require `/allow-dependencies-change`.", + "", + ...removalLines, + "", + `- Current SHA: ${markdownCode(headSha ?? "")}`, + "", + "A later push that adds or changes dependency graph entries will require a fresh security approval.", + ].join("\n"); +} + export function renderAutoscrubbedDependencyComment({ baseBranch, lockfileChanges, commitSha }) { const safeBranch = sanitizeDisplayValue(baseBranch ?? "main"); const fileLines = lockfileChanges.map((path) => `- ${markdownCode(path)}`); @@ -725,6 +749,27 @@ async function main() { return; } + const dependencyGraphChanges = await api.paginate( + `/repos/${owner}/${repo}/dependency-graph/compare/${pullRequest.base?.sha}...${pullRequest.head?.sha}`, + ); + if (isRemovalOnlyDependencyGraphChange(dependencyGraphChanges)) { + if (mode === "detect") { + await setOutput("autoscrub", "false"); + } + await upsertComment( + existingGuardComment, + renderRemovalOnlyDependencyComment({ + dependencyGraphChanges, + headSha: pullRequest.head?.sha, + }), + ); + await writeSummary( + "## Dependency Guard\n\nDependency removals are informational and do not require security approval.", + ); + console.log("Dependency removals detected; guard is informational."); + return; + } + const { isSecurityMember, isRepositoryAdmin } = createGuardApproverChecks({ api, owner, diff --git a/test/scripts/dependency-guard-script.test.ts b/test/scripts/dependency-guard-script.test.ts index 532e42d64327..5b39bce75104 100644 --- a/test/scripts/dependency-guard-script.test.ts +++ b/test/scripts/dependency-guard-script.test.ts @@ -21,11 +21,13 @@ import { isDependencyManifest, isDependencyGuardTrustedForHead, isPackageLockfile, + isRemovalOnlyDependencyGraphChange, readBoundedGitHubErrorText, renderAuthorizedDependencyComment, renderAutoscrubbedDependencyComment, renderBlockedDependencyComment, renderClearedDependencyGuardComment, + renderRemovalOnlyDependencyComment, renderTrustedDependencyComment, sanitizeDisplayValue, securityApproverSet, @@ -93,6 +95,43 @@ describe("dependency guard script", () => { ).toEqual(["optionalDependencies", "peerDependencies", "overrides", "packageManager", "pnpm"]); }); + it("allows only dependency graph removals without approval", () => { + expect( + isRemovalOnlyDependencyGraphChange([ + { change_type: "removed", name: "a" }, + { change_type: "removed", name: "b" }, + ]), + ).toBe(true); + expect( + isRemovalOnlyDependencyGraphChange([ + { change_type: "removed", name: "a" }, + { change_type: "added", name: "b" }, + ]), + ).toBe(false); + expect(isRemovalOnlyDependencyGraphChange([{ change_type: "changed", name: "a" }])).toBe(false); + expect(isRemovalOnlyDependencyGraphChange([])).toBe(false); + }); + + it("renders dependency removals as informational", () => { + const body = renderRemovalOnlyDependencyComment({ + dependencyGraphChanges: [ + { + change_type: "removed", + manifest: "extensions/example/package.json", + name: "example-dependency", + }, + ], + headSha, + }); + + expect(body).toContain("Dependency removals noted"); + expect(body).toContain("does not require `/allow-dependencies-change`"); + expect(body).toContain("Removed `example-dependency`"); + expect(body).toContain("`extensions/example/package.json`"); + expect(body).toContain(headSha); + expect(body).not.toContain("changes are blocked"); + }); + it("accepts only security-member override commands for the current head sha", () => { const comments = [ { diff --git a/test/scripts/dependency-guard-workflow.test.ts b/test/scripts/dependency-guard-workflow.test.ts index ac37ab5d947d..77db72efff65 100644 --- a/test/scripts/dependency-guard-workflow.test.ts +++ b/test/scripts/dependency-guard-workflow.test.ts @@ -221,6 +221,15 @@ describe("dependency guard workflow", () => { expect(script).toContain("process.exitCode = 1"); }); + it("keeps removal-only dependency changes informational", () => { + const script = readFileSync("scripts/github/dependency-guard.mjs", "utf8"); + + expect(script).toContain("isRemovalOnlyDependencyGraphChange"); + expect(script).toContain("Dependency removals detected; guard is informational."); + expect(script).toContain("/dependency-graph/compare/"); + expect(script).toContain('change.change_type === "removed"'); + }); + it("cleans dependency label and guard comment after successful autoscrub", () => { const script = readFileSync("scripts/github/dependency-guard.mjs", "utf8"); const autoscrubCommitIndex = script.indexOf("const commit = await createAutoscrubCommit");