Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions actions/setup/js/check_membership.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
const { parseRequiredPermissions, parseAllowedBots, checkRepositoryPermission, checkBotStatus, isAllowedBot, isConfusedDeputyAttack } = require("./check_permissions_utils.cjs");
const { writeDenialSummary } = require("./pre_activation_summary.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { withRetry, isTransientError } = require("./error_recovery.cjs");

/**
* Attempt to authorize the actor via the bots allowlist.
Expand Down Expand Up @@ -121,11 +122,26 @@ async function main() {
}

try {
const response = await github.rest.pulls.get({
owner,
repo,
pull_number: pullNumber,
});
const response = await withRetry(
() =>
github.rest.pulls.get({
owner,
repo,
pull_number: pullNumber,
}),
{
maxRetries: 3,
initialDelayMs: 2500,
maxDelayMs: 30000,
backoffMultiplier: 2,
jitterMs: 0,
shouldRetry: error => {
const status = error?.response?.status ?? error?.status;
return (typeof status === "number" && status >= 500) || isTransientError(error);
},
},
"pull request provenance check"
);
const pullRequest = response?.data;
const headRepo = pullRequest?.head?.repo?.full_name;
const baseRepo = pullRequest?.base?.repo?.full_name;
Expand Down
99 changes: 99 additions & 0 deletions actions/setup/js/check_membership.test.cjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createRequire } from "module";

const realRequire = createRequire(import.meta.url);

describe("check_membership.cjs", () => {
let mockCore;
Expand Down Expand Up @@ -96,6 +99,9 @@ describe("check_membership.cjs", () => {
},
};
}
if (modulePath === "./error_recovery.cjs") {
return realRequire(modulePath);
}
throw new Error(`Module not found: ${modulePath}`);
};

Expand Down Expand Up @@ -241,6 +247,99 @@ describe("check_membership.cjs", () => {
expect(mockGithub.rest.repos.getCollaboratorPermissionLevel).not.toHaveBeenCalled();
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "fork_pull_request");
});

it("should retry a transient pull request provenance failure", async () => {
vi.useFakeTimers();
try {
mockContext.eventName = "workflow_dispatch";
mockContext.actor = "github-actions[bot]";
mockContext.payload = {
inputs: {
aw_context: JSON.stringify({
command_name: "triage",
actor: "octocat",
item_type: "pull_request",
item_number: "42",
}),
},
};
process.env.GH_AW_REQUIRED_ROLES = "write";
const apiError = Object.assign(new Error("Internal Server Error"), { status: 500 });
mockGithub.rest.pulls.get.mockRejectedValueOnce(apiError).mockResolvedValue({
data: {
head: { repo: { full_name: "testorg/testrepo" } },
base: { repo: { full_name: "testorg/testrepo" } },
},
});
mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue({
data: { permission: "write" },
});

const runPromise = runScript();
await vi.runAllTimersAsync();
await runPromise;

expect(mockGithub.rest.pulls.get).toHaveBeenCalledTimes(2);
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "authorized");
} finally {
vi.useRealTimers();
}
});

it("should not retry a permanent pull request provenance failure", async () => {
mockContext.eventName = "workflow_dispatch";
mockContext.actor = "github-actions[bot]";
mockContext.payload = {
inputs: {
aw_context: JSON.stringify({
command_name: "triage",
actor: "octocat",
item_type: "pull_request",
item_number: "42",
}),
},
};
process.env.GH_AW_REQUIRED_ROLES = "write";
const apiError = Object.assign(new Error("Not Found"), { status: 404 });
mockGithub.rest.pulls.get.mockRejectedValue(apiError);

await runScript();

expect(mockGithub.rest.pulls.get).toHaveBeenCalledTimes(1);
expect(mockCore.setOutput).toHaveBeenCalledWith("is_team_member", "false");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "api_error");
});

it("should fail closed after exhausting transient provenance retries", async () => {
Comment thread
Copilot marked this conversation as resolved.
vi.useFakeTimers();
try {
mockContext.eventName = "workflow_dispatch";
mockContext.actor = "github-actions[bot]";
mockContext.payload = {
inputs: {
aw_context: JSON.stringify({
command_name: "triage",
actor: "octocat",
item_type: "pull_request",
item_number: "42",
}),
},
};
process.env.GH_AW_REQUIRED_ROLES = "write";
const apiError = Object.assign(new Error("Internal Server Error"), { status: 500 });
mockGithub.rest.pulls.get.mockRejectedValue(apiError);

const runPromise = runScript();
await vi.runAllTimersAsync();
await runPromise;

expect(mockGithub.rest.pulls.get).toHaveBeenCalledTimes(4);
expect(mockCore.setOutput).toHaveBeenCalledWith("is_team_member", "false");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "api_error");
} finally {
vi.useRealTimers();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] Both new tests cover the transient-500 path, but the PR description also claims "Do not retry permanent API failures" — that invariant isn't exercised by a test.

💡 Suggested regression test

Add a case where mockGithub.rest.pulls.get rejects with a non-retryable error (e.g. status: 404) and assert it's called exactly once (no retries) and still fails closed with result: "api_error". This locks in the "preserve security behavior" claim and guards against a future change to shouldRetry accidentally widening the retry surface to permanent failures.

it("should not retry a permanent (404) provenance failure", async () => {
  const apiError = Object.assign(new Error("Not Found"), { status: 404 });
  mockGithub.rest.pulls.get.mockRejectedValue(apiError);
  await runScript();
  expect(mockGithub.rest.pulls.get).toHaveBeenCalledTimes(1);
  expect(mockCore.setOutput).toHaveBeenCalledWith("result", "api_error");
});

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the requested 404 provenance regression test in actions/setup/js/check_membership.test.cjs. It asserts pulls.get is called once and the check fails closed with result: "api_error"; targeted tests pass.

}
});
});

describe("configuration validation", () => {
Expand Down
Loading