openclaw/scripts/github/dependency-guard.mjs
Josh Avant 35263bd17a
fix(ci): stop obsolete security reviews after PR lifecycle changes (#160890)
* fix(ci): stop obsolete security reviews after PR lifecycle changes

* fix(ci): preserve security review evidence after merges
2026-09-28 21:34:29 -05:00

831 lines
28 KiB
JavaScript

#!/usr/bin/env node
// GitHub dependency-change guard: requests maintainer review and can autoscrub
// lockfile-only PR changes without executing contributor code.
import { appendFile } from "node:fs/promises";
import {
ObsoleteReviewError,
assertGuardUnchanged,
findMaintainerApproval,
finishGuard,
openGuard,
securityReviewContracts,
withApprovalRequest,
} from "./guard-review.mjs";
import {
GITHUB_API_REQUEST_TIMEOUT_MS,
GITHUB_ERROR_BODY_MAX_BYTES,
GITHUB_RESPONSE_BODY_MAX_BYTES,
GitHubDiffDataError,
GitHubRateLimitError,
GitHubReadTimeoutError,
createGitHubApi,
createIssueMutationHelpers,
normalizeGuardLoginSet,
readBoundedGitHubErrorText,
readBoundedGitHubJson,
sanitizeGuardDisplayValue,
} from "./guard-shared.mjs";
import { loadSecurityReviewPolicy } from "./security-review-policy.mjs";
/** Marker used to identify dependency guard comments. */
const dependencyChangeMarker = "<!-- openclaw:dependency-guard -->";
const dependencyGraphGuardMarker = securityReviewContracts.dependency.commentMarker;
const dependencyApprovalCommand = securityReviewContracts.dependency.approvalCommand;
export const dependencyChangedLabel = "dependencies-changed";
export {
GITHUB_API_REQUEST_TIMEOUT_MS,
GITHUB_ERROR_BODY_MAX_BYTES,
GITHUB_RESPONSE_BODY_MAX_BYTES,
readBoundedGitHubErrorText,
readBoundedGitHubJson,
};
const autoscrubCommitMessage = "chore: remove dependency lockfile change";
class AutoscrubUnavailableError extends Error {}
const dependencyManifestFields = [
"dependencies",
"devDependencies",
"optionalDependencies",
"peerDependencies",
"peerDependenciesMeta",
"bundleDependencies",
"bundledDependencies",
"dependenciesMeta",
"overrides",
"resolutions",
"packageManager",
"workspaces",
"pnpm",
"name",
"version",
"engines",
"os",
"cpu",
"libc",
];
/**
* @typedef {{ path: string, fields: string[], previousPath?: string }} DependencyManifestChange
* @typedef {{ kind: "unavailable" } |
* { kind: "blocked-by-dependency-manifest-fields", changes: DependencyManifestChange[] } |
* { kind: "blocked-by-other-dependency-files", files: string[] } |
* { kind: "failed", reason: string }} AutoscrubStatus
*/
export function dependencyFieldChanges(baseManifest, headManifest) {
const changes = [];
for (const field of dependencyManifestFields) {
if (stableJson(baseManifest?.[field] ?? null) !== stableJson(headManifest?.[field] ?? null)) {
changes.push(field);
}
}
return changes;
}
export function isRemovalOnlyDependencyGraphChange(changes) {
return changes.length > 0 && changes.every((change) => change.change_type === "removed");
}
/**
* @param {{
* dependencyFiles?: string[],
* lockfileChanges: string[],
* dependencyManifestChanges?: DependencyManifestChange[],
* }} options
*/
export function shouldAutoscrubDependencyLockfiles({
dependencyFiles = [],
lockfileChanges,
dependencyManifestChanges = [],
}) {
const { isPackageLockfile } = loadSecurityReviewPolicy();
return (
lockfileChanges.length > 0 &&
dependencyManifestChanges.length === 0 &&
dependencyFiles.every(isPackageLockfile)
);
}
export function canAutoscrubPullRequest({ owner, repo, pullRequest }) {
return autoscrubTargetRepository({ owner, repo, pullRequest }) !== null;
}
function autoscrubTargetRepository({ owner, repo, pullRequest }) {
const baseRepository = `${owner}/${repo}`;
const headRepository = pullRequest.head?.repo;
const headRepositoryName = headRepository?.full_name;
if (
typeof pullRequest.head?.ref === "string" &&
pullRequest.head.ref.length > 0 &&
typeof pullRequest.head?.sha === "string" &&
pullRequest.head.sha.length > 0
) {
if (headRepositoryName === baseRepository) {
return { owner, repo };
}
if (pullRequest.maintainer_can_modify === true && typeof headRepositoryName === "string") {
const [headOwner, headRepo] = headRepositoryName.split("/");
if (headOwner && headRepo) {
return { owner: headOwner, repo: headRepo };
}
}
}
return null;
}
function stableJson(value) {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return JSON.stringify(value);
}
const sorted = {};
for (const key of Object.keys(value).toSorted((left, right) => left.localeCompare(right))) {
sorted[key] = value[key];
}
return JSON.stringify(sorted);
}
export function markdownCode(value) {
return `\`${sanitizeGuardDisplayValue(value).replaceAll("`", "\\`")}\``;
}
function shellQuote(value) {
return `'${sanitizeGuardDisplayValue(value).replaceAll("'", "'\\''")}'`;
}
export function dependencyGuardCommentAuthors(value) {
return normalizeGuardLoginSet(value, "github-actions[bot]");
}
export function isDependencyGuardMarkerComment(comment, marker, trustedAuthors) {
const login = comment.user?.login?.toLowerCase();
return Boolean(login && trustedAuthors.has(login) && comment.body?.includes(marker));
}
function renderDependencyChangeLines({
lockfileChanges,
dependencyFiles = [],
dependencyManifestChanges,
}) {
const files = new Set([...lockfileChanges, ...dependencyFiles]);
for (const change of dependencyManifestChanges) {
if (change.previousPath) {
files.add(change.previousPath);
}
files.add(change.path);
}
return [...files].map((path) => `- ${markdownCode(path)}`);
}
function renderApprovedDependencyComment(approval, changes) {
return [
dependencyGraphGuardMarker,
"",
approval.kind === "author"
? "### ⚠️ Dependency graph changes"
: "### ✅ Dependency graph changes approved",
"",
approval.kind === "author"
? "This maintainer PR changes the dependency graph. This comment is informational because the PR author has repository Maintain or Admin access."
: "A maintainer approved this revision with an explicit dependency approval comment.",
"",
`- Current SHA: ${markdownCode(approval.sha)}`,
`- Maintainer: @${sanitizeGuardDisplayValue(approval.login)}`,
`- Repository role: ${markdownCode(approval.role)}`,
...(approval.kind === "comment" ? [`- Approval comment: ${approval.url}`] : []),
"",
...(approval.kind === "author"
? ["These dependency graph changes were made:", ...renderDependencyChangeLines(changes), ""]
: []),
approval.kind === "author"
? "Carefully review these changes before merging."
: "A later push requires a fresh approval comment for an external contributor's PR.",
].join("\n");
}
export function renderRemovalOnlyDependencyComment({ dependencyGraphChanges, headSha }) {
const removalLines = dependencyGraphChanges.map(
(change) =>
`- Removed ${markdownCode(change.name ?? "<unknown dependency>")} from ${markdownCode(change.manifest ?? "<unknown 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 additional maintainer approval.",
"",
...removalLines,
"",
`- Current SHA: ${markdownCode(headSha ?? "<head-sha>")}`,
"",
"A later push that adds or changes dependency graph entries will require a fresh maintainer approval.",
].join("\n");
}
export function renderAutoscrubbedDependencyComment({
baseBranch,
lockfileChanges,
commitSha,
mergeBaseSha,
}) {
const safeBranch = sanitizeGuardDisplayValue(baseBranch ?? "main");
const fileLines = lockfileChanges.map((path) => `- ${markdownCode(path)}`);
return `${dependencyGraphGuardMarker}
### Dependency lockfile changes were removed
This PR did not change dependency graph fields in package manifests and had no maintainer approval, so the workflow restored the lockfiles to this PR's merge base automatically.
Restored lockfiles:
${fileLines.join("\n")}
- Target branch: ${markdownCode(safeBranch)}
- Merge base: ${markdownCode(mergeBaseSha)}
- Cleanup commit: ${markdownCode(commitSha)}
- Workflow action: restored each listed lockfile to its merge-base state, removing files added by this PR, and pushed the cleanup commit to this PR head.
No action is needed unless this PR intentionally requires a dependency update. If it does, explain the update in the PR and request a maintainer's review.`;
}
export function isAutoscrubbedDependencyComment(comment) {
return comment?.body?.includes("### Dependency lockfile changes were removed") === true;
}
export function renderClearedDependencyGuardComment({ headSha }) {
return [
dependencyGraphGuardMarker,
"",
"### Dependency graph guard cleared",
"",
`This PR no longer has dependency changes awaiting review. A future dependency graph change from an external contributor requires a maintainer's ${markdownCode(dependencyApprovalCommand)} comment after the guard notice identifies that revision.`,
"",
`- Current SHA: ${markdownCode(headSha ?? "<head-sha>")}`,
].join("\n");
}
/**
* @param {{
* baseRepository: string,
* baseBranch?: string,
* headSha?: string,
* lockfileChanges: string[],
* dependencyManifestChanges: DependencyManifestChange[],
* dependencyFiles?: string[],
* autoscrubStatus?: AutoscrubStatus | null,
* }} options
*/
export function renderBlockedDependencyComment({
baseRepository,
baseBranch,
headSha,
lockfileChanges,
dependencyManifestChanges,
autoscrubStatus,
dependencyFiles = [],
}) {
const safeBranch = sanitizeGuardDisplayValue(baseBranch ?? "main");
const autoscrubLines = renderAutoscrubStatusLines(autoscrubStatus);
const removalSteps =
lockfileChanges.length > 0
? [
"",
"To remove lockfile changes, restore them from this PR's merge base:",
"",
"```bash",
`git fetch ${shellQuote(`https://github.com/${baseRepository}.git`)} ${shellQuote(safeBranch)}`,
`git restore --source="$(git merge-base HEAD FETCH_HEAD)" --staged --worktree -- ${lockfileChanges.map(shellQuote).join(" ")}`,
`git commit -m ${shellQuote(autoscrubCommitMessage)}`,
"git push",
"```",
]
: [];
return [
dependencyGraphGuardMarker,
"",
"### ⚠️ Maintainer dependency review required",
"",
"This external contributor PR changes the dependency graph. A maintainer must review these changes before merging.",
"",
`Current SHA: ${markdownCode(headSha ?? "<head-sha>")}`,
"",
"These dependency graph changes were made:",
...renderDependencyChangeLines({ lockfileChanges, dependencyFiles, dependencyManifestChanges }),
...autoscrubLines,
...removalSteps,
"",
"After reviewing the changes, post a new PR comment containing only approval commands, each on its own line:",
"",
"```text",
dependencyApprovalCommand,
"```",
"",
"A later push requires a fresh approval comment.",
].join("\n");
}
function renderAutoscrubStatusLines(status) {
if (!status) {
return [];
}
if (status.kind === "unavailable") {
return [
"",
"Automatic lockfile cleanup is best effort. These lockfile changes remain in this PR. If they are unintentional, remove them using the commands below. Otherwise, a maintainer can review and approve them with `/allow-dependencies-change`.",
];
}
if (status.kind === "blocked-by-dependency-manifest-fields") {
return [
"",
"Auto-scrub was not attempted because this PR changes package manifest dependency graph fields:",
...renderDependencyChangeLines({
lockfileChanges: [],
dependencyManifestChanges: status.changes,
}),
"",
"Dependency graph changes require maintainer review. Please remove lockfile changes manually if they are not needed.",
];
}
if (status.kind === "blocked-by-other-dependency-files") {
return [
"",
"Auto-scrub was not attempted because this PR also changes dependency-related files that are not package lockfiles:",
...status.files.map((path) => `- ${markdownCode(path)}`),
"",
"Please remove lockfile changes manually if they are not needed.",
];
}
if (status.kind === "failed") {
return [
"",
`Auto-scrub was attempted, but GitHub rejected the cleanup commit: ${markdownCode(status.reason)}. Please remove the lockfile changes manually.`,
];
}
return [];
}
export function githubApi(token, options = {}) {
const api = createGitHubApi(token, { ...options, userAgent: "openclaw-dependency-guard" });
return {
...api,
graphql: async (query, variables) => {
const result = await api.request("/graphql", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (Array.isArray(result.errors) && result.errors.length > 0) {
const error = new Error(
result.errors.map((entry) => entry.message ?? "GraphQL error").join("; "),
);
error.errors = result.errors;
error.data = result.data;
throw error;
}
return result.data;
},
};
}
function decodeContentFile(payload) {
if (!payload || payload.type !== "file" || typeof payload.content !== "string") {
return null;
}
return Buffer.from(payload.content, payload.encoding ?? "base64").toString("utf8");
}
async function readJsonFileAtRef(api, options) {
const payload = await readContentFileMetadataAtRef(api, options);
const text = decodeContentFile(payload);
return text ? JSON.parse(text) : null;
}
async function readContentFileMetadataAtRef(api, { owner, repo, path, ref }) {
if (!ref) {
return null;
}
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
return api
.request(`/repos/${owner}/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`)
.catch((error) => {
if (error?.status === 404) {
return null;
}
throw error;
});
}
async function readBase64FileAtRef(api, { owner, repo, path, ref }) {
const file = await readContentFileMetadataAtRef(api, { owner, repo, path, ref });
if (!file) {
return null;
}
if (file.encoding === "base64" && typeof file.content === "string" && file.content.length > 0) {
return file.content.replace(/\s+/gu, "");
}
if (typeof file.sha === "string" && file.sha.length > 0) {
const blob = await api.request(`/repos/${owner}/${repo}/git/blobs/${file.sha}`);
if (blob.encoding === "base64" && typeof blob.content === "string" && blob.content.length > 0) {
return blob.content.replace(/\s+/gu, "");
}
}
throw new Error(`Unable to read base64 file contents for ${path}`);
}
async function readDependencyMergeBase(api, { owner, repo, pullRequest }) {
// Match the PR diff, not unrelated updates on the target branch. Page two
// omits file patches; only the comparison metadata is needed.
const baseSha = pullRequest.base?.sha;
const comparison = await api.request(
`/repos/${owner}/${repo}/compare/${baseSha}...${pullRequest.head?.sha}?per_page=1&page=2`,
);
if (
comparison?.base_commit?.sha !== baseSha ||
!/^[a-f0-9]{40}$/u.test(comparison?.merge_base_commit?.sha ?? "")
) {
throw new GitHubDiffDataError("GitHub returned an invalid dependency merge base.");
}
return comparison.merge_base_commit.sha;
}
async function collectDependencyManifestChanges(api, { owner, repo, pullRequest, files }) {
const { isDependencyManifest } = loadSecurityReviewPolicy();
const changes = [];
let mergeBaseSha;
for (const file of files) {
const basePath = file.previous_filename ?? file.filename;
const headPath = file.filename;
if (!isDependencyManifest(basePath) && !isDependencyManifest(headPath)) {
continue;
}
if (!mergeBaseSha) {
mergeBaseSha = await readDependencyMergeBase(api, { owner, repo, pullRequest });
}
const baseManifest = isDependencyManifest(basePath)
? await readJsonFileAtRef(api, { owner, repo, path: basePath, ref: mergeBaseSha })
: null;
const headManifest = isDependencyManifest(headPath)
? await readJsonFileAtRef(api, { owner, repo, path: headPath, ref: pullRequest.head?.sha })
: null;
const fields = dependencyFieldChanges(baseManifest, headManifest);
if (fields.length > 0 || basePath !== headPath) {
changes.push({
path: headPath,
fields,
...(basePath !== headPath ? { previousPath: basePath } : {}),
});
}
}
return changes;
}
export async function createAutoscrubCommit(
{ baseApi, writeApi, guard },
{ owner, repo, pullRequest, lockfileChanges, targetRepository },
) {
const headSha = pullRequest.head.sha;
const headRef = pullRequest.head.ref;
const writeOwner = targetRepository.owner;
const writeRepo = targetRepository.repo;
const mergeBaseSha = await readDependencyMergeBase(baseApi, { owner, repo, pullRequest });
const additions = [];
const deletions = [];
for (const path of lockfileChanges) {
const contents = await readBase64FileAtRef(baseApi, {
owner,
repo,
path,
ref: mergeBaseSha,
});
if (contents) {
additions.push({ path, contents });
} else {
deletions.push({ path });
}
}
// Recheck after reading file contents: neither an old workflow event nor the
// detection job authorizes a write after the PR or its approval has changed.
await assertGuardUnchanged(guard, { allowMerged: false });
if (await findMaintainerApproval(guard)) {
return null;
}
await assertGuardUnchanged(guard, { allowMerged: false });
const data = await writeApi
.graphql(
`mutation CreateAutoscrubCommit($input: CreateCommitOnBranchInput!) {
createCommitOnBranch(input: $input) {
commit {
oid
}
}
}`,
{
input: {
branch: {
repositoryNameWithOwner: `${writeOwner}/${writeRepo}`,
branchName: headRef,
},
expectedHeadOid: headSha,
fileChanges: { additions, deletions },
message: { headline: autoscrubCommitMessage },
},
},
)
.catch((error) => {
// Only a rejected cleanup mutation can fall back. Read failures, rate
// limits, stale heads, and uncertain writes keep their existing handling.
const forbidden =
!error?.data?.createCommitOnBranch &&
Array.isArray(error?.errors) &&
error.errors.length > 0 &&
error.errors.every(
(entry) =>
entry.type === "FORBIDDEN" &&
(!entry.path || (entry.path.length === 1 && entry.path[0] === "createCommitOnBranch")),
);
if (
!(error instanceof GitHubRateLimitError) &&
(forbidden ||
(error?.status === 403 &&
/Resource not accessible by (?:integration|personal access token)/u.test(
error.message,
)))
) {
throw new AutoscrubUnavailableError(
"GitHub did not authorize automatic lockfile cleanup.",
{ cause: error },
);
}
throw error;
});
return { sha: data.createCommitOnBranch.commit.oid, mergeBaseSha };
}
async function writeSummary(markdown) {
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
if (!summaryPath) {
console.log(markdown);
return;
}
await appendFile(summaryPath, `${markdown}\n`);
}
async function setOutput(name, value) {
const outputPath = process.env.GITHUB_OUTPUT;
if (!outputPath) {
return;
}
await appendFile(outputPath, `${name}=${value}\n`);
}
export async function reviewDependencyChanges(
prepared,
mode = process.env.OPENCLAW_DEPENDENCY_GUARD_MODE ?? "enforce",
) {
const guard = await openGuard(securityReviewContracts.dependency, prepared);
if (!guard) {
return true;
}
const { api, owner, repo, pullRequest, issuePath, files } = guard;
const { isDependencyFile, isDependencyManifest, isPackageLockfile } = loadSecurityReviewPolicy();
if (!["detect", "autoscrub", "enforce"].includes(mode)) {
throw new Error(`Unknown dependency guard mode: ${mode}`);
}
const dependencyFiles = [
...new Set(files.flatMap((file) => [file.filename, file.previous_filename])),
]
.filter((filename) => typeof filename === "string" && isDependencyFile(filename))
.toSorted((left, right) => left.localeCompare(right));
const lockfileChanges = dependencyFiles.filter(isPackageLockfile);
const dependencyManifestChanges = await collectDependencyManifestChanges(api, {
owner,
repo,
pullRequest,
files,
});
const dependencyGraphFiles = [
...dependencyFiles,
...dependencyManifestChanges.map((change) => change.path),
].toSorted((left, right) => left.localeCompare(right));
const approval = dependencyGraphFiles.length > 0 ? await findMaintainerApproval(guard) : null;
let dependencyGraphChanges = [];
// A package removal does not waive review of patches or workspace policy.
if (dependencyGraphFiles.length > 0 && !approval && dependencyFiles.every(isPackageLockfile)) {
dependencyGraphChanges = await api.paginate(
`/repos/${owner}/${repo}/dependency-graph/compare/${pullRequest.base?.sha}...${pullRequest.head?.sha}`,
);
}
// Moving dependency files can change package resolution even when GitHub's
// graph reports only removals. A renamed lockfile can also become an artifact
// that automatic cleanup must preserve for maintainer review.
const renamedDependencyFile = files.some(
(file) =>
file.previous_filename &&
[file.previous_filename, file.filename].some(
(filename) => isDependencyFile(filename) || isDependencyManifest(filename),
),
);
const removalOnly =
!renamedDependencyFile && isRemovalOnlyDependencyGraphChange(dependencyGraphChanges);
const autoscrubCandidate =
!renamedDependencyFile &&
shouldAutoscrubDependencyLockfiles({
dependencyFiles,
lockfileChanges,
dependencyManifestChanges,
});
const autoscrubTarget =
autoscrubCandidate && pullRequest.state === "open" && !approval && !removalOnly
? autoscrubTargetRepository({ owner, repo, pullRequest })
: null;
if (mode === "detect") {
await setOutput("autoscrub", String(Boolean(autoscrubTarget)));
if (autoscrubTarget) {
await setOutput("autoscrub-owner", autoscrubTarget.owner);
await setOutput("autoscrub-repository", autoscrubTarget.repo);
}
await writeSummary(
"## Dependency Guard\n\nDependency analysis complete; the final guard job publishes the review result.",
);
return true;
}
const comments = await api.paginate(`${issuePath}/comments`);
const labels = await api.paginate(`${issuePath}/labels`);
const trustedCommentAuthors = dependencyGuardCommentAuthors(
process.env.OPENCLAW_DEPENDENCY_GUARD_COMMENT_BOTS,
);
const findComment = (marker) =>
comments.find((comment) =>
isDependencyGuardMarkerComment(comment, marker, trustedCommentAuthors),
);
const existingGuardComment = findComment(dependencyGraphGuardMarker);
const { removeLabelIfPresent, addLabelIfMissing, deleteCommentIfPresent, upsertComment } =
createIssueMutationHelpers({
api,
issuePath,
owner,
repo,
labelNames: new Set(labels.map((label) => label.name)),
});
// Consolidate the former awareness and enforcement comments into one notice.
await deleteCommentIfPresent(findComment(dependencyChangeMarker));
if (dependencyGraphFiles.length === 0) {
await removeLabelIfPresent(dependencyChangedLabel);
if (existingGuardComment && !isAutoscrubbedDependencyComment(existingGuardComment)) {
await upsertComment(
existingGuardComment,
renderClearedDependencyGuardComment({ headSha: pullRequest.head.sha }),
);
}
await writeSummary("## Dependency Guard\n\nNo dependency-related file changes detected.");
if (mode === "enforce") {
return await finishGuard(guard, securityReviewContracts.dependency.success.clear);
}
return true;
}
await addLabelIfMissing(dependencyChangedLabel);
let autoscrubStatus = null;
if (mode === "autoscrub") {
if (autoscrubTarget) {
try {
const token = process.env.OPENCLAW_DEPENDENCY_GUARD_AUTOSCRUB_TOKEN;
if (!token) {
throw new AutoscrubUnavailableError(
"No write token could be created for automatic lockfile cleanup.",
);
}
const commit = await createAutoscrubCommit(
{ baseApi: api, writeApi: githubApi(token), guard },
{ owner, repo, pullRequest, lockfileChanges, targetRepository: autoscrubTarget },
);
if (!commit) {
await writeSummary(
"## Dependency Guard\n\nMaintainer approval arrived; lockfile changes were preserved.",
);
return true;
}
await removeLabelIfPresent(dependencyChangedLabel);
const body = renderAutoscrubbedDependencyComment({
baseBranch: pullRequest.base.ref,
lockfileChanges,
commitSha: commit.sha,
mergeBaseSha: commit.mergeBaseSha,
});
await upsertComment(existingGuardComment, body);
await writeSummary(body);
return true;
} catch (error) {
if (
error instanceof GitHubRateLimitError ||
error instanceof GitHubReadTimeoutError ||
error instanceof GitHubDiffDataError ||
error instanceof ObsoleteReviewError
) {
throw error;
}
if (error instanceof AutoscrubUnavailableError) {
autoscrubStatus = { kind: "unavailable" };
console.log(error.message);
} else {
autoscrubStatus = {
kind: "failed",
reason: error instanceof Error ? error.message : String(error),
};
console.warn(`Autoscrub failed: ${autoscrubStatus.reason}`);
}
}
} else {
await writeSummary(
"## Dependency Guard\n\nNo unapproved lockfile-only change needs autoscrub.",
);
return true;
}
} else if (autoscrubCandidate && !approval && !removalOnly) {
// Explain the remaining changes on every evaluation, without persisting
// an earlier cleanup outcome across PR or permission changes.
autoscrubStatus = { kind: "unavailable" };
} else if (lockfileChanges.length > 0 && dependencyManifestChanges.length > 0) {
autoscrubStatus = {
kind: "blocked-by-dependency-manifest-fields",
changes: dependencyManifestChanges,
};
} else if (lockfileChanges.length > 0) {
const otherFiles = dependencyFiles.filter((path) => !isPackageLockfile(path));
if (otherFiles.length > 0) {
autoscrubStatus = { kind: "blocked-by-other-dependency-files", files: otherFiles };
}
}
if (mode === "enforce") {
const allowed = await finishGuard(
guard,
securityReviewContracts.dependency.success[removalOnly ? "removals" : "approved"],
);
if (allowed) {
const body = removalOnly
? renderRemovalOnlyDependencyComment({
dependencyGraphChanges,
headSha: pullRequest.head.sha,
})
: withApprovalRequest(
guard,
renderApprovedDependencyComment(guard.approval, {
lockfileChanges,
dependencyFiles,
dependencyManifestChanges,
}),
);
await upsertComment(existingGuardComment, body);
await writeSummary(body);
return true;
}
}
const body = withApprovalRequest(
guard,
renderBlockedDependencyComment({
baseRepository: `${owner}/${repo}`,
baseBranch: pullRequest.base.ref,
headSha: pullRequest.head.sha,
lockfileChanges,
dependencyManifestChanges,
autoscrubStatus,
dependencyFiles,
}),
);
if (mode === "autoscrub") {
try {
await assertGuardUnchanged(guard);
} catch (error) {
// A lifecycle stop must not hide a cleanup mutation that already failed.
if (autoscrubStatus?.kind === "failed") {
throw new Error(`Dependency lockfile autoscrub failed: ${autoscrubStatus.reason}`, {
cause: error,
});
}
throw error;
}
}
await upsertComment(existingGuardComment, body);
await writeSummary(body);
if (autoscrubStatus?.kind === "failed") {
throw new Error(`Dependency lockfile autoscrub failed: ${autoscrubStatus.reason}`);
}
return false;
}
if (import.meta.url === `file://${process.argv[1]}`) {
reviewDependencyChanges().catch(
/** @param {unknown} error */ (error) => {
if (error instanceof ObsoleteReviewError) {
console.log(error.message);
return;
}
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
},
);
}