diff --git a/actions/setup/js/checkout_pr_branch.cjs b/actions/setup/js/checkout_pr_branch.cjs index 4043ff7aff0..260427c8e6b 100644 --- a/actions/setup/js/checkout_pr_branch.cjs +++ b/actions/setup/js/checkout_pr_branch.cjs @@ -129,19 +129,48 @@ async function assertTrustedCheckoutRuntime() { throw new Error("Refusing PR checkout: unable to determine triggering actor"); } - const { data: permissionData } = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username: actor, - }); - - const permission = permissionData?.permission || "none"; - const hasWriteOrHigher = TRUSTED_CHECKOUT_PERMISSIONS.includes(permission); - if (!hasWriteOrHigher) { - throw new Error(`Refusing PR checkout: actor '${actor}' has '${permission}' permission (requires write or higher)`); + // Bot and app actors (e.g. Copilot, dependabot[bot]) are not regular GitHub + // users and cannot be resolved via the collaborators API (returns 404). + // Trust them implicitly: the non-fork repository check above already ensures + // the workflow is running in a controlled context. + const senderType = context.payload.sender?.type; + if (senderType === "Bot") { + core.info(`Runtime safety check passed for bot/app actor '${actor}' (sender type: ${senderType})`); + return; } - core.info(`Runtime safety check passed for actor '${actor}' with '${permission}' permission`); + try { + const { data: permissionData } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: actor, + }); + + const permission = permissionData?.permission || "none"; + const hasWriteOrHigher = TRUSTED_CHECKOUT_PERMISSIONS.includes(permission); + if (!hasWriteOrHigher) { + throw new Error(`Refusing PR checkout: actor '${actor}' has '${permission}' permission (requires write or higher)`); + } + + core.info(`Runtime safety check passed for actor '${actor}' with '${permission}' permission`); + } catch (err) { + // A 404 here is ambiguous: it can indicate either a non-user app/bot actor + // or a real user that is not a collaborator. Disambiguate via users API. + // Real users resolve via users.getByUsername; app/bot actors return 404. + if (err.status === 404) { + try { + await github.rest.users.getByUsername({ username: actor }); + throw new Error(`Refusing PR checkout: actor '${actor}' is not a collaborator (requires write or higher)`); + } catch (userErr) { + if (userErr.status === 404) { + core.info(`Runtime safety check passed for app actor '${actor}' (not a regular user)`); + return; + } + throw userErr; + } + } + throw err; + } } async function main() { diff --git a/actions/setup/js/checkout_pr_branch.test.cjs b/actions/setup/js/checkout_pr_branch.test.cjs index e8b577f2fea..277ae0f2782 100644 --- a/actions/setup/js/checkout_pr_branch.test.cjs +++ b/actions/setup/js/checkout_pr_branch.test.cjs @@ -82,6 +82,13 @@ describe("checkout_pr_branch.cjs", () => { }, }), }, + users: { + getByUsername: vi.fn().mockResolvedValue({ + data: { + login: "test-actor", + }, + }), + }, pulls: { get: vi.fn().mockResolvedValue({ data: { @@ -257,6 +264,66 @@ If the pull request is still open, verify that: expect(mockCore.setOutput).toHaveBeenCalledWith("checkout_pr_success", "false"); expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("requires write or higher")); }); + + it("should allow checkout for Bot actor without calling the collaborator API", async () => { + mockContext.actor = "Copilot"; + mockContext.payload.sender = { login: "Copilot", type: "Bot" }; + + await runScript(); + + expect(mockGithub.rest.repos.getCollaboratorPermissionLevel).not.toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith("Runtime safety check passed for bot/app actor 'Copilot' (sender type: Bot)"); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + expect(mockExec.exec).toHaveBeenCalledWith("git", ["fetch", "origin", "feature-branch", "--depth=2"]); + expect(mockExec.exec).toHaveBeenCalledWith("git", ["checkout", "feature-branch"]); + }); + + it("should allow checkout when collaborator API returns 404 (app actor without sender type)", async () => { + mockContext.actor = "Copilot"; + // No sender.type set — simulates an event payload without type info + const notAUserError = Object.assign(new Error("Copilot is not a user"), { status: 404 }); + mockGithub.rest.repos.getCollaboratorPermissionLevel.mockRejectedValue(notAUserError); + mockGithub.rest.users.getByUsername.mockRejectedValue(notAUserError); + + await runScript(); + + expect(mockGithub.rest.repos.getCollaboratorPermissionLevel).toHaveBeenCalled(); + expect(mockGithub.rest.users.getByUsername).toHaveBeenCalledWith({ username: "Copilot" }); + expect(mockCore.info).toHaveBeenCalledWith("Runtime safety check passed for app actor 'Copilot' (not a regular user)"); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + expect(mockExec.exec).toHaveBeenCalledWith("git", ["fetch", "origin", "feature-branch", "--depth=2"]); + expect(mockExec.exec).toHaveBeenCalledWith("git", ["checkout", "feature-branch"]); + }); + + it("should fail when collaborator API returns 404 for a regular non-collaborator user", async () => { + mockContext.actor = "real-user"; + const notCollaboratorError = Object.assign(new Error("Not Found"), { status: 404 }); + mockGithub.rest.repos.getCollaboratorPermissionLevel.mockRejectedValue(notCollaboratorError); + mockGithub.rest.users.getByUsername.mockResolvedValue({ + data: { + login: "real-user", + }, + }); + + await runScript(); + + expect(mockGithub.rest.repos.getCollaboratorPermissionLevel).toHaveBeenCalled(); + expect(mockGithub.rest.users.getByUsername).toHaveBeenCalledWith({ username: "real-user" }); + expect(mockExec.exec).not.toHaveBeenCalledWith("git", ["fetch", "origin", "feature-branch", "--depth=2"]); + expect(mockExec.exec).not.toHaveBeenCalledWith("git", ["checkout", "feature-branch"]); + expect(mockCore.setOutput).toHaveBeenCalledWith("checkout_pr_success", "false"); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("is not a collaborator")); + }); + + it("should fail when collaborator API returns a non-404 error", async () => { + const serverError = Object.assign(new Error("Internal Server Error"), { status: 500 }); + mockGithub.rest.repos.getCollaboratorPermissionLevel.mockRejectedValue(serverError); + + await runScript(); + + expect(mockGithub.rest.repos.getCollaboratorPermissionLevel).toHaveBeenCalled(); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Internal Server Error")); + }); }); it("should handle git fetch errors", async () => {