Skip to content
60 changes: 57 additions & 3 deletions actions/setup/js/push_to_pull_request_branch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,38 @@ async function getBundlePreApplyFiles(exec, gitOptions, rangeBaseRef, bundleRef)
.filter(Boolean);
}

/**
* Checks if a git push stderr output indicates that the 'workflows' scope is required.
* GitHub rejects branch pushes that contain .github/workflows/** changes when the token
* lacks the 'workflows' scope, producing one of two known error message variants.
*
* @param {string} stderr - The captured stderr from a failed git push
* @returns {boolean} true when the rejection is due to missing 'workflows' scope
*/
function isWorkflowsScopeRejection(stderr) {
if (!stderr) return false;
const lower = stderr.toLowerCase();
return lower.includes("`workflows` scope") || lower.includes("workflow can be created or updated due to timeout");
Comment thread
pelikhan marked this conversation as resolved.
}

/**
* Builds the typed result and logs actionable guidance when a branch push fails
* because the token lacks the 'workflows' scope.
*
* @param {string} context - Short label identifying the push path (e.g. "Review branch", "Fallback branch")
* @param {typeof core} core - Actions core logger
* @returns {{ success: false, error_type: "workflows_scope_required", error: string }}
*/
function buildWorkflowsScopeError(context, core) {
core.error(`${context} push rejected: the branch includes changes to workflow files (.github/workflows/**) that require the 'workflows' scope on the push token.`);
core.error("To allow this workflow to push workflow file changes, configure 'push-to-pull-request-branch.allow-workflows: true' together with a GitHub App in 'safe-outputs.github-app'.");
return {
success: false,
error_type: "workflows_scope_required",
error: `${context} push rejected: the branch includes changes to workflow files (.github/workflows/**) requiring the 'workflows' scope. The token used for the safe-outputs checkout does not have this scope. Fix: configure 'push-to-pull-request-branch.allow-workflows: true' with a GitHub App in 'safe-outputs.github-app', or exclude workflow files from the changeset.`,
};
}

/**
* Main handler factory for push_to_pull_request_branch
* Returns a message handler function that processes individual push_to_pull_request_branch messages
Expand Down Expand Up @@ -1013,11 +1045,24 @@ async function main(config = {}) {
await exec.exec("git", ["checkout", "-b", reviewBranchName], baseGitOpts);
core.info(`Created review branch: ${reviewBranchName}`);

// Push the review branch
await exec.exec("git", ["push", "origin", reviewBranchName], {
// Push the review branch — use getExecOutput to capture stderr so we
// can detect GitHub's "workflows scope required" rejection and surface
// a typed, actionable error instead of a bare git exit-1.
const reviewPushOutput = await exec.getExecOutput("git", ["push", "origin", reviewBranchName], {
env: { ...process.env, ...gitAuthEnv },
...baseGitOpts,
ignoreReturnCode: true,
});
if (reviewPushOutput.exitCode !== 0) {
const reviewPushStderr = (reviewPushOutput.stderr || "").trim();
// GitHub rejects pushes to branches containing .github/workflows/** changes
// when the token lacks the 'workflows' scope. Surface this as a typed
// error so the caller can distinguish it from a generic push failure.
if (isWorkflowsScopeRejection(reviewPushStderr)) {
return buildWorkflowsScopeError("Review branch", core);
}
throw new Error(`git push origin ${reviewBranchName} failed (exit code ${reviewPushOutput.exitCode}): ${reviewPushStderr}`);
}
core.info(`Pushed review branch: ${reviewBranchName}`);

// Create PR from review branch to original branch
Expand Down Expand Up @@ -1166,10 +1211,19 @@ async function main(config = {}) {
core.warning(`Non-fast-forward push detected; creating fallback pull request from '${fallbackBranchName}' to '${branchName}'`);
try {
await exec.exec("git", ["checkout", "-b", fallbackBranchName], baseGitOpts);
await exec.exec("git", ["push", "origin", fallbackBranchName], {
// Use getExecOutput to capture stderr for 'workflows' scope diagnostics
const fallbackPushOutput = await exec.getExecOutput("git", ["push", "origin", fallbackBranchName], {
env: { ...process.env, ...gitAuthEnv },
...baseGitOpts,
ignoreReturnCode: true,
});
if (fallbackPushOutput.exitCode !== 0) {
const fallbackPushStderr = (fallbackPushOutput.stderr || "").trim();
if (isWorkflowsScopeRejection(fallbackPushStderr)) {
return buildWorkflowsScopeError("Fallback branch", core);
}
throw new Error(`git push origin ${fallbackBranchName} failed (exit code ${fallbackPushOutput.exitCode}): ${fallbackPushStderr}`);
}

const fallbackBody = [
"> [!NOTE]",
Expand Down
158 changes: 158 additions & 0 deletions actions/setup/js/push_to_pull_request_branch.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1187,6 +1187,68 @@ index 0000000..abc1234
expect(mockGithub.rest.pulls.create).not.toHaveBeenCalled();
});

it("should return typed workflows_scope_required error when fallback branch push is rejected for missing workflows scope", async () => {
createPatchFile("fallback-branch-workflows-scope-rejection");

mockExec.exec.mockResolvedValueOnce(0); // fetch
mockExec.exec.mockResolvedValueOnce(0); // rev-parse
mockExec.exec.mockResolvedValueOnce(0); // checkout

mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "before-sha\n", stderr: "" }); // git rev-parse HEAD (before patch)

mockExec.exec.mockResolvedValueOnce(0); // git am

const originalGetExecOutput = mockExec.getExecOutput;
mockExec.getExecOutput = vi.fn().mockImplementation(async (cmd, args, options) => {
const argList = Array.isArray(args) ? args : [];
if (argList[0] === "rev-parse" && argList[1] === "origin/feature-branch^{commit}") {
return { exitCode: 0, stdout: "1111111111111111111111111111111111111111\n", stderr: "" };
}
if (argList[0] === "rev-list" && argList[1] === "--merges") {
return { exitCode: 0, stdout: "0\n", stderr: "" };
}
if (argList[0] === "rev-list" && argList[1] === "--parents") {
return {
exitCode: 0,
stdout: "2222222222222222222222222222222222222222 1111111111111111111111111111111111111111\n",
stderr: "",
};
}
if (argList[0] === "ls-remote" && argList[2] === "refs/heads/feature-branch") {
return { exitCode: 0, stdout: "1111111111111111111111111111111111111111\trefs/heads/feature-branch\n", stderr: "" };
}
if (argList[0] === "log") {
return { exitCode: 0, stdout: "Test commit\n", stderr: "" };
}
if (argList[0] === "diff-tree") {
return { exitCode: 0, stdout: "", stderr: "" };
}
if (cmd === "git" && argList[0] === "push" && argList[1] === "origin") {
return {
exitCode: 1,
stdout: "",
stderr: "! [remote rejected] branch -> branch (`workflows` scope may be required.)",
};
}
return originalGetExecOutput(cmd, args, options);
});

// GraphQL call fails, triggering fallback to git push
mockGithub.graphql.mockRejectedValueOnce(new Error("GraphQL error: branch protection"));
// Git push fails with non-fast-forward, triggering fallback branch creation
mockExec.exec.mockRejectedValueOnce(new Error("! [rejected] feature-branch -> feature-branch (non-fast-forward)"));

const module = await loadModule();
const handler = await module.main({});
const result = await handler({ branch: "fallback-branch-workflows-scope-rejection" }, {});

expect(result.success).toBe(false);
expect(result.error_type).toBe("workflows_scope_required");
expect(result.error).toContain("'workflows' scope");
expect(result.error).toContain("allow-workflows");
expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("'workflows' scope"));
});

it("should diagnose deleted branch when push fails", async () => {
const patchPath = createPatchFile("should-diagnose-deleted-branch-when-push-fails");

Expand Down Expand Up @@ -1284,6 +1346,102 @@ index 0000000..abc1234
});
});

// ──────────────────────────────────────────────────────
// Threat Detection: Review Branch Push
// ──────────────────────────────────────────────────────

describe("threat detection: review branch push", () => {
let savedGetExecOutput;

beforeEach(() => {
savedGetExecOutput = mockExec.getExecOutput;
});

afterEach(() => {
mockExec.getExecOutput = savedGetExecOutput;
});

it("should return typed workflows_scope_required error when review branch push is rejected for missing workflows scope (timeout variant)", async () => {
process.env.GH_AW_DETECTION_CONCLUSION = "warning";
createPatchFile("review-branch-workflows-scope-timeout");

const originalGetExecOutput = mockExec.getExecOutput;
mockExec.getExecOutput = vi.fn().mockImplementation(async (cmd, args, options) => {
const argList = Array.isArray(args) ? args : [];
if (cmd === "git" && argList[0] === "push" && argList[1] === "origin") {
return {
exitCode: 1,
stdout: "",
stderr: "remote: error: Unable to determine if workflow can be created or updated due to timeout\n" + "error: failed to push some refs to 'https://github.com/test-owner/test-repo.git'",
};
}
return originalGetExecOutput(cmd, args, options);
});
Comment thread
Copilot marked this conversation as resolved.

const module = await loadModule();
const handler = await module.main({});
const result = await handler({ branch: "review-branch-workflows-scope-timeout" }, {});

expect(result.success).toBe(false);
expect(result.error_type).toBe("workflows_scope_required");
expect(result.error).toContain("'workflows' scope");
expect(result.error).toContain("allow-workflows");
expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("'workflows' scope"));
expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("allow-workflows"));
// Should NOT fall through to the generic "Failed to create review PR" catch message
const errorCalls = mockCore.error.mock.calls.map(c => c[0]);
expect(errorCalls.some(msg => msg.includes("Failed to create review PR"))).toBe(false);
});

it("should return typed workflows_scope_required error when review branch push is rejected with backtick workflows scope message", async () => {
process.env.GH_AW_DETECTION_CONCLUSION = "warning";
createPatchFile("review-branch-workflows-scope-backtick");

const originalGetExecOutput = mockExec.getExecOutput;
mockExec.getExecOutput = vi.fn().mockImplementation(async (cmd, args, options) => {
const argList = Array.isArray(args) ? args : [];
if (cmd === "git" && argList[0] === "push" && argList[1] === "origin") {
return {
exitCode: 1,
stdout: "",
stderr: "! [remote rejected] branch -> branch (`workflows` scope may be required.)",
};
}
return originalGetExecOutput(cmd, args, options);
});
Comment thread
Copilot marked this conversation as resolved.

const module = await loadModule();
const handler = await module.main({});
const result = await handler({ branch: "review-branch-workflows-scope-backtick" }, {});

expect(result.success).toBe(false);
expect(result.error_type).toBe("workflows_scope_required");
});

it("should wrap generic review branch push failure in actionable error message", async () => {
process.env.GH_AW_DETECTION_CONCLUSION = "warning";
createPatchFile("review-branch-generic-push-failure");

const originalGetExecOutput = mockExec.getExecOutput;
mockExec.getExecOutput = vi.fn().mockImplementation(async (cmd, args, options) => {
const argList = Array.isArray(args) ? args : [];
if (cmd === "git" && argList[0] === "push" && argList[1] === "origin") {
return { exitCode: 1, stdout: "", stderr: "error: authentication failed for 'https://github.com/test-owner/test-repo.git'" };
}
return originalGetExecOutput(cmd, args, options);
});
Comment thread
Copilot marked this conversation as resolved.

const module = await loadModule();
const handler = await module.main({});
const result = await handler({ branch: "review-branch-generic-push-failure" }, {});

expect(result.success).toBe(false);
// Generic push failure should NOT be typed as workflows_scope_required
expect(result.error_type).toBeUndefined();
expect(result.error).toContain("Failed to create review PR");
});
});
Comment thread
pelikhan marked this conversation as resolved.
Comment thread
pelikhan marked this conversation as resolved.

// ──────────────────────────────────────────────────────
// Empty Commit / No Changes Handling
// ──────────────────────────────────────────────────────
Expand Down
Loading