Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
34e5934
Add approve workflow run safe output
Copilot Aug 13, 2026
529f1d2
Preserve approval budget on validation failures
Copilot Aug 13, 2026
a2b80a6
Add draft ADR for approve-workflow-run safe output (PR #52541)
github-actions[bot] Aug 13, 2026
274953a
Merge branch 'main' into copilot/add-safe-output-type
github-actions[bot] Aug 13, 2026
6ccb138
Fix workflow-run approval validation
Copilot Aug 13, 2026
ab48671
Merge branch 'main' into copilot/add-safe-output-type
github-actions[bot] Aug 13, 2026
267816b
Merge branch 'main' into copilot/add-safe-output-type
github-actions[bot] Aug 13, 2026
9158266
Fix workflow approval status guard
Copilot Aug 13, 2026
6277b83
Merge branch 'main' into copilot/add-safe-output-type
github-actions[bot] Aug 14, 2026
6ce18e1
Merge branch 'main' into copilot/add-safe-output-type
github-actions[bot] Aug 15, 2026
12d020e
Merge branch 'main' into copilot/add-safe-output-type
github-actions[bot] Aug 15, 2026
243e934
Require explicit workflow approval credentials
Copilot Aug 15, 2026
74c285b
Merge branch 'main' into copilot/add-safe-output-type
github-actions[bot] Aug 15, 2026
58860c4
Merge branch 'main' into copilot/add-safe-output-type
github-actions[bot] Aug 15, 2026
ca18c9d
Merge branch 'main' into copilot/add-safe-output-type
github-actions[bot] Aug 15, 2026
eaa21dc
Correct workflow approval status documentation
Copilot Aug 15, 2026
fe3df73
Authorize workflow run approvals by pull request
Copilot Aug 15, 2026
833dc9e
Add workflow approval integration test
Copilot Aug 15, 2026
b1c23eb
Document workflow run approval output
Copilot Aug 15, 2026
d5a9ebd
Mark workflow approval experimental
Copilot Aug 15, 2026
e7937a1
Block approval for protected pull request changes
Copilot Aug 15, 2026
94f3339
Require explicit fork approval opt-in
Copilot Aug 15, 2026
1d65b92
Merge branch 'main' into copilot/add-safe-output-type
github-actions[bot] Aug 15, 2026
90b0c57
Merge branch 'main' into copilot/add-safe-output-type
github-actions[bot] Aug 15, 2026
bc028d2
Merge branch 'main' into copilot/add-safe-output-type
github-actions[bot] Aug 15, 2026
b32f118
Preserve workflow approval retries
Copilot Aug 15, 2026
3473c92
Merge branch 'main' into copilot/add-safe-output-type
github-actions[bot] Aug 15, 2026
1be5064
Define PR-scoped workflow run approvals
Copilot Aug 15, 2026
711d477
Merge branch 'main' into copilot/add-safe-output-type
github-actions[bot] Aug 15, 2026
8bf4c20
Restrict workflow run approvals by workflow
Copilot Aug 15, 2026
ef4b786
Merge branch 'main' into copilot/add-safe-output-type
github-actions[bot] Aug 15, 2026
a93d775
Fix JavaScript lint formatting
Copilot Aug 15, 2026
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
96 changes: 96 additions & 0 deletions actions/setup/js/approve_workflow_run.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// @ts-check
/// <reference types="@actions/github-script" />

/**
* @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction
*/

const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { isStagedMode } = require("./safe_output_helpers.cjs");
const { logStagedPreviewInfo } = require("./staged_preview.cjs");

/** @type {string} Safe output type handled by this module */
const HANDLER_TYPE = "approve_workflow_run";

/**
* @param {unknown} value
* @returns {number | undefined}
*/
function parseRunId(value) {
if (typeof value !== "number" && typeof value !== "string") return undefined;
const normalized = typeof value === "string" ? value.trim() : value;
if (normalized === "") return undefined;
const runId = Number(normalized);
if (!Number.isSafeInteger(runId) || runId <= 0) return undefined;
return runId;
}

/**
* Main handler factory for approve_workflow_run.
* @type {HandlerFactoryFunction}
*/
async function main(config = {}) {
const maxCount = config.max || 1;
const githubClient = await createAuthenticatedGitHubClient(config);
const isStaged = isStagedMode(config);
let processedCount = 0;

core.info(`Approve workflow run configuration: max=${maxCount}`);

return async function handleApproveWorkflowRun(message) {
if (processedCount >= maxCount) {
core.warning(`Skipping ${HANDLER_TYPE}: max count of ${maxCount} reached`);
return { success: false, error: `Max count of ${maxCount} reached` };
}

const runId = parseRunId(message.run_id);
if (!runId) {
const error = "run_id must be a positive integer";
core.warning(error);
return { success: false, error };
}

try {
const { data: run } = await githubClient.rest.actions.getWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: runId,
Comment thread
github-actions[bot] marked this conversation as resolved.
Comment thread
github-actions[bot] marked this conversation as resolved.
});

if (run.event !== "pull_request" || !Array.isArray(run.pull_requests) || run.pull_requests.length === 0) {
const error = `Workflow run ${runId} is not associated with a pull request`;
core.warning(error);
return { success: false, error };
Comment thread
github-actions[bot] marked this conversation as resolved.
}

if (run.conclusion !== "action_required") {
Comment thread
github-actions[bot] marked this conversation as resolved.
Outdated
const error = `Workflow run ${runId} is not awaiting approval (conclusion: ${run.conclusion || "none"})`;
core.warning(error);
return { success: false, error };
}

processedCount++;
Comment thread
github-actions[bot] marked this conversation as resolved.

if (isStaged) {
logStagedPreviewInfo(`Would approve workflow run ${runId}`);
return { success: true, staged: true, run_id: runId, url: run.html_url };
}

Comment thread
github-actions[bot] marked this conversation as resolved.
await githubClient.rest.actions.approveWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: runId,
});

core.info(`Approved workflow run ${runId}: ${run.html_url}`);
return { success: true, run_id: runId, url: run.html_url };
} catch (error) {
const errorMessage = getErrorMessage(error);
core.error(`Failed to approve workflow run ${runId}: ${errorMessage}`);
return { success: false, error: errorMessage };
}
};
}

module.exports = { main, parseRunId };
142 changes: 142 additions & 0 deletions actions/setup/js/approve_workflow_run.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// @ts-check
import { beforeEach, describe, expect, it, vi } from "vitest";

const mockGetWorkflowRun = vi.fn();
const mockApproveWorkflowRun = vi.fn();

global.core = {
info: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
};

global.context = {
repo: { owner: "test-owner", repo: "test-repo" },
};

global.github = {
rest: {
actions: {
getWorkflowRun: mockGetWorkflowRun,
approveWorkflowRun: mockApproveWorkflowRun,
},
},
};

const pendingPullRequestRun = {
event: "pull_request",
conclusion: "action_required",
html_url: "https://github.com/test-owner/test-repo/actions/runs/123",
pull_requests: [{ number: 42 }],
};

describe("approve_workflow_run", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetWorkflowRun.mockResolvedValue({ data: pendingPullRequestRun });
mockApproveWorkflowRun.mockResolvedValue({ status: 201 });
});

it("approves an eligible pull request workflow run", async () => {
const { main } = require("./approve_workflow_run.cjs");
const handler = await main();

const result = await handler({ run_id: 123 }, {});

expect(result).toEqual({
success: true,
run_id: 123,
url: pendingPullRequestRun.html_url,
});
expect(mockApproveWorkflowRun).toHaveBeenCalledWith({
owner: "test-owner",
repo: "test-repo",
run_id: 123,
});
});

it("accepts a decimal run ID string", async () => {
const { main } = require("./approve_workflow_run.cjs");
const handler = await main();

const result = await handler({ run_id: "123" }, {});

expect(result.success).toBe(true);
expect(mockGetWorkflowRun).toHaveBeenCalledWith(expect.objectContaining({ run_id: 123 }));
});

it.each([undefined, "", 0, -1, 1.5, "abc", "12abc"])("rejects invalid run ID %j", async runId => {
const { main } = require("./approve_workflow_run.cjs");
const handler = await main();

const result = await handler({ run_id: runId }, {});

expect(result.success).toBe(false);
expect(result.error).toContain("positive integer");
expect(mockGetWorkflowRun).not.toHaveBeenCalled();
});

it("rejects runs that are not associated with a pull request", async () => {
mockGetWorkflowRun.mockResolvedValue({
data: { ...pendingPullRequestRun, event: "push", pull_requests: [] },
});
const { main } = require("./approve_workflow_run.cjs");
const handler = await main();

const result = await handler({ run_id: 123 }, {});

expect(result.success).toBe(false);
expect(result.error).toContain("not associated with a pull request");
expect(mockApproveWorkflowRun).not.toHaveBeenCalled();
});

it("rejects runs that are not awaiting approval", async () => {
mockGetWorkflowRun.mockResolvedValue({
data: { ...pendingPullRequestRun, conclusion: "success" },
});
const { main } = require("./approve_workflow_run.cjs");
const handler = await main();

const result = await handler({ run_id: 123 }, {});

expect(result.success).toBe(false);
expect(result.error).toContain("not awaiting approval");
expect(mockApproveWorkflowRun).not.toHaveBeenCalled();
});

it("previews without approving in staged mode", async () => {
const { main } = require("./approve_workflow_run.cjs");
const handler = await main({ staged: true });

const result = await handler({ run_id: 123 }, {});

expect(result.success).toBe(true);
expect(result.staged).toBe(true);
expect(mockApproveWorkflowRun).not.toHaveBeenCalled();
});

it("enforces the configured maximum", async () => {
const { main } = require("./approve_workflow_run.cjs");
const handler = await main({ max: 1 });

expect((await handler({ run_id: 123 }, {})).success).toBe(true);
const result = await handler({ run_id: 124 }, {});

expect(result.success).toBe(false);
expect(result.error).toContain("Max count of 1 reached");
});

it("does not consume the maximum for an ineligible run", async () => {
mockGetWorkflowRun
.mockResolvedValueOnce({
data: { ...pendingPullRequestRun, conclusion: "success" },
})
.mockResolvedValueOnce({ data: pendingPullRequestRun });
const { main } = require("./approve_workflow_run.cjs");
const handler = await main({ max: 1 });
Comment thread
github-actions[bot] marked this conversation as resolved.
Outdated

expect((await handler({ run_id: 122 }, {})).success).toBe(false);
expect((await handler({ run_id: 123 }, {})).success).toBe(true);
expect(mockApproveWorkflowRun).toHaveBeenCalledTimes(1);
});
});
2 changes: 2 additions & 0 deletions actions/setup/js/safe_output_handler_manager.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const HANDLER_MAP = {
merge_pull_request: "./merge_pull_request.cjs",
close_pull_request: "./close_pull_request.cjs",
mark_pull_request_as_ready_for_review: "./mark_pull_request_as_ready_for_review.cjs",
approve_workflow_run: "./approve_workflow_run.cjs",
hide_comment: "./hide_comment.cjs",
set_issue_type: "./set_issue_type.cjs",
set_issue_field: "./set_issue_field.cjs",
Expand Down Expand Up @@ -169,6 +170,7 @@ const THREAT_WARNING_ABORT_TYPES = new Set([
"close_pull_request",
"merge_pull_request",
"mark_pull_request_as_ready_for_review",
"approve_workflow_run",
"resolve_pull_request_review_thread",
"dismiss_pull_request_review",
"add_labels",
Expand Down
24 changes: 24 additions & 0 deletions actions/setup/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -1897,6 +1897,30 @@
"additionalProperties": false
}
},
{
"name": "approve_workflow_run",
"description": "Approve a GitHub Actions workflow run that is awaiting approval for a fork pull request. Use the run ID shown in the workflow run URL. The handler verifies that the run belongs to a pull request and has an action_required conclusion before approving it.",
"inputSchema": {
"type": "object",
"required": ["run_id"],
"properties": {
"run_id": {
"type": ["number", "string"],
"description": "Positive integer workflow run ID to approve (for example, 123456789 from /actions/runs/123456789).",
"x-synonyms": ["runId", "workflow_run_id"]
},
"secrecy": {
"type": "string",
"description": "Confidentiality level of the message content (e.g., \"public\", \"internal\", \"private\")."
},
"integrity": {
"type": "string",
"description": "Trustworthiness level of the message source (e.g., \"low\", \"medium\", \"high\")."
}
},
"additionalProperties": false
}
},
{
"name": "push_repo_memory",
"description": "Validate repo-memory files are within configured size limits before the workflow completes. Call this after writing files to memory to check that the total size is within limits. Returns an error if files are too large, with guidance on how to reduce memory size so the memory can be saved successfully.",
Expand Down
7 changes: 7 additions & 0 deletions actions/setup/js/types/safe-outputs-config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ interface MarkPullRequestAsReadyForReviewConfig extends SafeOutputConfig {
target?: string;
}

/**
* Configuration for approving pending workflow runs from fork pull requests
*/
interface ApproveWorkflowRunConfig extends SafeOutputConfig {}

/**
* Configuration for adding comments to issues or PRs
*/
Expand Down Expand Up @@ -354,6 +359,7 @@ type SpecificSafeOutputConfig =
| CloseIssueConfig
| ClosePullRequestConfig
| MarkPullRequestAsReadyForReviewConfig
| ApproveWorkflowRunConfig
| AddCommentConfig
| CreatePullRequestConfig
| CreatePullRequestReviewCommentConfig
Expand Down Expand Up @@ -391,6 +397,7 @@ export {
CloseIssueConfig,
ClosePullRequestConfig,
MarkPullRequestAsReadyForReviewConfig,
ApproveWorkflowRunConfig,
AddCommentConfig,
CreatePullRequestConfig,
CreatePullRequestReviewCommentConfig,
Expand Down
11 changes: 11 additions & 0 deletions actions/setup/js/types/safe-outputs.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@ interface MarkPullRequestAsReadyForReviewItem extends BaseSafeOutputItem {
pull_request_number?: number | string;
}

/**
* JSONL item for approving a pending workflow run from a fork pull request
*/
interface ApproveWorkflowRunItem extends BaseSafeOutputItem {
type: "approve_workflow_run";
/** Positive integer workflow run ID */
run_id: number | string;
}

/**
* JSONL item for adding a comment to an issue or PR
*/
Expand Down Expand Up @@ -496,6 +505,7 @@ type SafeOutputItem =
| CloseIssueItem
| ClosePullRequestItem
| MarkPullRequestAsReadyForReviewItem
| ApproveWorkflowRunItem
| AddCommentItem
| CommentMemoryItem
| CreatePullRequestItem
Expand Down Expand Up @@ -541,6 +551,7 @@ export {
CloseIssueItem,
ClosePullRequestItem,
MarkPullRequestAsReadyForReviewItem,
ApproveWorkflowRunItem,
AddCommentItem,
CommentMemoryItem,
CreatePullRequestItem,
Expand Down
14 changes: 14 additions & 0 deletions docs/src/content/docs/reference/safe-outputs-pull-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This page is the primary reference for pull-request-focused safe outputs:
- [`create-pull-request`](#pull-request-creation-create-pull-request)
- [`update-pull-request`](#pull-request-updates-update-pull-request)
- [`close-pull-request`](#close-pull-request-close-pull-request)
- [`approve-workflow-run`](#approve-workflow-run-approve-workflow-run)
- [`merge-pull-request`](#merge-pull-request-merge-pull-request) (experimental)
- [`create-pull-request-review-comment`](#pr-review-comments-create-pull-request-review-comment)
- [`submit-pull-request-review`](#submit-pr-review-submit-pull-request-review)
Expand Down Expand Up @@ -183,6 +184,19 @@ safe-outputs:
github-token: ${{ secrets.SOME_CUSTOM_TOKEN }} # optional custom token for permissions
```

## Approve Workflow Run (`approve-workflow-run:`)

Approves a GitHub Actions workflow run that is waiting for the repository's fork pull request approval gate. The agent supplies the positive integer `run_id`; the handler verifies that the run belongs to a pull request and has an `action_required` conclusion before calling GitHub's workflow-run approval API.

```yaml wrap
safe-outputs:
approve-workflow-run:
max: 1
staged: false
```

This operation requires `actions: write`. Use `staged: true` to preview approvals without executing them.

## Merge Pull Request (`merge-pull-request:`)

:::caution[Experimental]
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ The tables below summarize the built-in safe output handlers. `noop`, `missing-t
| [Create PR](/gh-aw/reference/safe-outputs-pull-requests/#pull-request-creation-create-pull-request) | `create-pull-request` | Create pull requests with code changes (default max: 1, configurable) |
| [Update PR](/gh-aw/reference/safe-outputs-pull-requests/#pull-request-updates-update-pull-request) | `update-pull-request` | Update PR title or body (max: 1) |
| [Close PR](/gh-aw/reference/safe-outputs-pull-requests/#close-pull-request-close-pull-request) | `close-pull-request` | Close pull requests without merging (max: 10) |
| [Approve Workflow Run](/gh-aw/reference/safe-outputs-pull-requests/#approve-workflow-run-approve-workflow-run) | `approve-workflow-run` | Approve a pending workflow run from a fork pull request (max: 1) |
| [Merge PR](/gh-aw/reference/safe-outputs-pull-requests/#merge-pull-request-merge-pull-request) | `merge-pull-request` | Merge pull requests after policy gates pass (max: 1, experimental) |
| [PR Review Comments](/gh-aw/reference/safe-outputs-pull-requests/#pr-review-comments-create-pull-request-review-comment) | `create-pull-request-review-comment` | Create review comments on code lines (max: 10) |
| [Reply to PR Review Comment](/gh-aw/reference/safe-outputs-pull-requests/#reply-to-pr-review-comment-reply-to-pull-request-review-comment) | `reply-to-pull-request-review-comment` | Reply to existing review comments (max: 10) |
Expand Down
Loading
Loading