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
70 changes: 69 additions & 1 deletion actions/setup/js/pr_helpers.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// @ts-check

const { getErrorMessage } = require("./error_helpers.cjs");

/**
* Detect if a pull request is from a fork repository.
*
Expand Down Expand Up @@ -110,4 +112,70 @@ function buildBranchInstruction(effectiveBaseBranch, resolvedDefaultBranch) {
return `IMPORTANT: Create your branch from the '${effectiveBaseBranch}' branch${notClause}.`;
}

module.exports = { detectForkPR, getPullRequestNumber, resolvePullRequestRepo, buildBranchInstruction };
/**
* Check whether a branch is safe to push to.
*
* Performs two security checks:
* 1. Ensures the branch is not the repository's default branch.
* 2. When `checkBranchProtection` is true, queries the branch protection API to
* verify the branch has no protection rules.
*
* Returns null when the push is safe to proceed, or a string error message that
* should be surfaced as a hard failure when the push must be blocked.
*
* @param {any} githubClient - Octokit REST client
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {string} branchName - Target branch to validate
* @param {boolean} checkBranchProtection - Whether to call the branch protection API
* @returns {Promise<string|null>} Error message if push is blocked, null if safe
*/
async function checkBranchPushable(githubClient, owner, repo, branchName, checkBranchProtection) {
// Check whether the branch is the repository default branch
let defaultBranch = null;
try {
const { data: repoData } = await githubClient.rest.repos.get({ owner, repo });
defaultBranch = repoData.default_branch;
} catch (repoError) {
core.warning(`Could not check repository default branch: ${getErrorMessage(repoError)}`);
}

if (defaultBranch && branchName === defaultBranch) {
return `Cannot push to branch "${branchName}": this is the repository's default branch. Agents must not push directly to the default branch.`;
}

// Check whether the branch has protection rules
if (checkBranchProtection) {
let isBranchProtected = false;
try {
await githubClient.rest.repos.getBranchProtection({ owner, repo, branch: branchName });
// Successful response means branch protection rules exist
isBranchProtected = true;
} catch (protectionError) {
const protectionStatus = protectionError && typeof protectionError === "object" && "status" in protectionError ? protectionError.status : undefined;
if (protectionStatus === 404) {
// 404 means no protection rules – safe to proceed
core.info(`Branch "${branchName}" has no protection rules`);
} else if (protectionStatus === 403) {
// 403 means the token lacks permission to read branch protection rules.
// The GitHub platform will still enforce branch protection at push time,
// so warn and allow the push to proceed.
core.warning(`Could not check branch protection rules for "${branchName}" (insufficient permissions): ${getErrorMessage(protectionError)}`);
} else {
// Unexpected errors (5xx, network failures, etc.) – fail closed to
// avoid bypassing branch protection due to transient API issues.
return `Cannot verify branch protection rules for "${branchName}": ${getErrorMessage(protectionError)}. Push blocked to prevent accidental writes to protected branches.`;
}
}

if (isBranchProtected) {
return `Cannot push to branch "${branchName}": this branch has protection rules. Agents must not push directly to protected branches.`;
}
} else {
core.info(`Branch protection check skipped (check-branch-protection: false)`);
}

return null;
}

module.exports = { detectForkPR, getPullRequestNumber, resolvePullRequestRepo, buildBranchInstruction, checkBranchPushable };
57 changes: 57 additions & 0 deletions actions/setup/js/pr_helpers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -351,3 +351,60 @@ describe("buildBranchInstruction", () => {
expect(instruction).not.toContain("NOT from");
});
});

describe("checkBranchPushable", () => {
const { checkBranchPushable } = require("./pr_helpers.cjs");

const mockCore = { info: vi.fn(), warning: vi.fn(), error: vi.fn() };
beforeEach(() => {
global.core = mockCore;
vi.clearAllMocks();
});
Comment on lines +355 to +362
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

The new checkBranchPushable tests mutate global.core in beforeEach but never restore the previous value. This can leak global state into other test suites and cause order-dependent failures. Capture the original global.core before overriding it and restore it in an afterEach (or delete it if it was originally undefined).

Copilot uses AI. Check for mistakes.

const makeClient = (defaultBranch, protectionStatus) => ({
rest: {
repos: {
get: vi.fn().mockResolvedValue({ data: { default_branch: defaultBranch } }),
getBranchProtection: protectionStatus === null ? vi.fn().mockResolvedValue({}) : vi.fn().mockRejectedValue(Object.assign(new Error("error"), { status: protectionStatus })),
},
},
});

it("returns null when branch is not default and has no protection rules (404)", async () => {
const client = makeClient("main", 404);
const result = await checkBranchPushable(client, "owner", "repo", "feature", true);
expect(result).toBeNull();
});

it("blocks push when branch is the default branch", async () => {
const client = makeClient("main", 404);
const result = await checkBranchPushable(client, "owner", "repo", "main", true);
expect(result).toContain("default branch");
});

it("blocks push when branch has protection rules", async () => {
const client = makeClient("main", null); // null status = successful getBranchProtection response
const result = await checkBranchPushable(client, "owner", "repo", "feature", true);
expect(result).toContain("protection rules");
});

it("returns null and skips protection check when checkBranchProtection is false", async () => {
const client = makeClient("main", null);
const result = await checkBranchPushable(client, "owner", "repo", "feature", false);
expect(result).toBeNull();
expect(client.rest.repos.getBranchProtection).not.toHaveBeenCalled();
});

it("returns error on unexpected protection check failure (5xx)", async () => {
const client = makeClient("main", 500);
const result = await checkBranchPushable(client, "owner", "repo", "feature", true);
expect(result).toContain("Cannot verify branch protection rules");
});

it("returns null and warns on 403 (insufficient permissions)", async () => {
const client = makeClient("main", 403);
const result = await checkBranchPushable(client, "owner", "repo", "feature", true);
expect(result).toBeNull();
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("insufficient permissions"));
});
});
59 changes: 7 additions & 52 deletions actions/setup/js/push_to_pull_request_branch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const { updateActivationCommentWithCommit, updateActivationComment } = require("
const { getErrorMessage } = require("./error_helpers.cjs");
const { normalizeBranchName } = require("./normalize_branch_name.cjs");
const { pushExtraEmptyCommit } = require("./extra_empty_commit.cjs");
const { detectForkPR } = require("./pr_helpers.cjs");
const { detectForkPR, checkBranchPushable } = require("./pr_helpers.cjs");
const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");
const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
const { checkFileProtection } = require("./manifest_file_helpers.cjs");
Expand Down Expand Up @@ -57,6 +57,7 @@ async function main(config = {}) {
const ifNoChanges = config.if_no_changes || "warn";
const ignoreMissingBranchFailure = config.ignore_missing_branch_failure === true;
const fallbackAsPullRequest = config.fallback_as_pull_request !== false;
const checkBranchProtection = config.check_branch_protection !== false;
const commitTitleSuffix = config.commit_title_suffix || "";
const maxSizeKb = config.max_patch_size ? parseInt(String(config.max_patch_size), 10) : 1024;
const maxCount = config.max || 0; // 0 means no limit
Expand Down Expand Up @@ -93,6 +94,7 @@ async function main(config = {}) {
core.info(`If no changes: ${ifNoChanges}`);
core.info(`Ignore missing branch failure: ${ignoreMissingBranchFailure}`);
core.info(`Fallback as pull request: ${fallbackAsPullRequest}`);
core.info(`Check branch protection: ${checkBranchProtection}`);
if (commitTitleSuffix) {
core.info(`Commit title suffix: ${commitTitleSuffix}`);
}
Expand Down Expand Up @@ -407,57 +409,10 @@ async function main(config = {}) {
// This prevents agents from pushing directly to branches that should only receive
// changes through reviewed pull requests.
{
// Check whether the branch is the repository default branch
let defaultBranch = null;
try {
const { data: repoData } = await githubClient.rest.repos.get({
owner: repoParts.owner,
repo: repoParts.repo,
});
defaultBranch = repoData.default_branch;
} catch (repoError) {
core.warning(`Could not check repository default branch: ${getErrorMessage(repoError)}`);
}

if (defaultBranch && branchName === defaultBranch) {
const msg = `Cannot push to branch "${branchName}": this is the repository's default branch. Agents must not push directly to the default branch.`;
core.error(msg);
return { success: false, error: msg };
}

// Check whether the branch has protection rules
let isBranchProtected = false;
try {
await githubClient.rest.repos.getBranchProtection({
owner: repoParts.owner,
repo: repoParts.repo,
branch: branchName,
});
// Successful response means branch protection rules exist
isBranchProtected = true;
} catch (protectionError) {
const protectionStatus = protectionError && typeof protectionError === "object" && "status" in protectionError ? protectionError.status : undefined;
if (protectionStatus === 404) {
// 404 means no protection rules – safe to proceed
core.info(`Branch "${branchName}" has no protection rules`);
} else if (protectionStatus === 403) {
// 403 means the token lacks permission to read branch protection rules.
// The GitHub platform will still enforce branch protection at push time,
// so warn and allow the push to proceed.
core.warning(`Could not check branch protection rules for "${branchName}" (insufficient permissions): ${getErrorMessage(protectionError)}`);
} else {
// Unexpected errors (5xx, network failures, etc.) – fail closed to
// avoid bypassing branch protection due to transient API issues.
const msg = `Cannot verify branch protection rules for "${branchName}": ${getErrorMessage(protectionError)}. Push blocked to prevent accidental writes to protected branches.`;
core.error(msg);
return { success: false, error: msg };
}
}

if (isBranchProtected) {
const msg = `Cannot push to branch "${branchName}": this branch has protection rules. Agents must not push directly to protected branches.`;
core.error(msg);
return { success: false, error: msg };
const blockReason = await checkBranchPushable(githubClient, repoParts.owner, repoParts.repo, branchName, checkBranchProtection);
if (blockReason) {
core.error(blockReason);
return { success: false, error: blockReason };
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# ADR-28365: Optional Branch Protection Pre-flight Check for `push-to-pull-request-branch`

**Date**: 2026-04-25
**Status**: Draft
**Deciders**: Unknown

---

## Part 1 — Narrative (Human-Friendly)

### Context

The `push-to-pull-request-branch` safe output handler performs a pre-flight check against the GitHub branch protection API (`GET /repos/{owner}/{repo}/branches/{branch}/protection`) before pushing. This call requires `administration: read` permission, which is a GitHub App-only scope not available on standard `GITHUB_TOKEN`. Without this scope, the API returns HTTP 403, which the handler silently swallowed—logging a warning and continuing—making the check effectively useless noise. At the same time, automatically granting `administration: read` to all workflows that use this handler would increase every deployment's permission surface area, even for teams that don't need or want the protection check.

### Decision

We will make the branch protection pre-flight check opt-out via a new `check-branch-protection` boolean frontmatter key (default `true`). When the check is enabled (the default), the permission compiler automatically adds `administration: read` to the GitHub App token so the API call succeeds. When `check-branch-protection: false` is set, neither the API call nor the `administration: read` permission is added. The default branch guard (blocking pushes to the repository's default branch) runs unconditionally regardless of this setting, because it uses the repos API rather than the branch protection API.

### Alternatives Considered

#### Alternative 1: Always require `administration: read` with no opt-out

All workflows using `push-to-pull-request-branch` would automatically receive `administration: read`. This eliminates the silent-failure problem and simplifies the implementation. It was rejected because it forces every team to grant a broad GitHub App scope even when they have no interest in the branch protection check and prefer minimal permission footprints.

#### Alternative 2: Remove the branch protection pre-flight check entirely

Eliminating the check avoids the permission problem without any new configuration surface. It was rejected because the check provides genuine defense-in-depth: it prevents agentic pushes to branches under review policies, catching misconfigurations before GitHub's push-time enforcement triggers a harder failure. The value of the check outweighs the complexity of making it configurable.

#### Alternative 3: Silently ignore 403 and keep the existing behavior

The current code already falls through on 403 with a warning, meaning the check is already a no-op for most GitHub App tokens. This was rejected because it perpetuates misleading log output and provides no security value while still requiring `administration: read` to appear in the schema. An explicit opt-out is more honest about what the check does and does not guarantee.

### Consequences

#### Positive
- The 403-swallow silent failure is eliminated: when the check is enabled, the token has the right scope and the check is meaningful.
- Users with strict permission policies can set `check-branch-protection: false` to avoid requesting `administration: read` entirely.
- The default branch guard (using the repos API) continues to run unconditionally, preserving the most critical safety check.
- Extracting the combined guard logic into a `checkBranchPushable` helper improves testability and isolation.

#### Negative
- Enabling the default increases the permission footprint of all existing `push-to-pull-request-branch` deployments that use GitHub App tokens (they gain `administration: read` automatically on upgrade).
- The configuration surface for the handler grows by one field, adding a new concept callers must understand.
- The `administration: read` permission is a GitHub App-only scope with no effect on `GITHUB_TOKEN`; this asymmetry may confuse users who rely on `GITHUB_TOKEN`.

#### Neutral
- The JSON schema for `push-to-pull-request-branch` gains a `check-branch-protection` boolean property, which may require documentation updates in tooling that consumes the schema.
- The permission computation in `ComputePermissionsForSafeOutputs` now branches on the effective `CheckBranchProtection` value, so permission calculations are no longer purely additive for this handler.

---

## Part 2 — Normative Specification (RFC 2119)

> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).

### Branch Protection Check Configuration

1. The `push-to-pull-request-branch` handler **MUST** treat `check-branch-protection` as an optional boolean key in workflow frontmatter, defaulting to `true` when absent.
2. When `check-branch-protection` is `true` (the default), the handler **MUST** call `GET /repos/{owner}/{repo}/branches/{branch}/protection` before executing the push.
3. When `check-branch-protection` is `false`, the handler **MUST NOT** call the branch protection API and **MUST NOT** request `administration: read` permission.
4. The handler **MUST** block the push and return an error when the branch protection API responds with a successful (2xx) status, indicating active protection rules exist.
5. The handler **MUST** permit the push when the branch protection API responds with HTTP 404 (no rules configured).
6. The handler **SHOULD** log a warning and permit the push when the branch protection API responds with HTTP 403 (insufficient permissions), because the GitHub platform still enforces protection at push time.
7. The handler **MUST** block the push when the branch protection API responds with any unexpected error (e.g., 5xx, network failure) to fail closed and prevent accidental writes to potentially protected branches.

### Default Branch Guard

1. The handler **MUST** check whether the target branch is the repository's default branch, regardless of the `check-branch-protection` setting.
2. The handler **MUST** block the push and return an error when the target branch equals the repository's default branch.
3. The handler **SHOULD** log a warning and continue when the repositories API call needed to resolve the default branch fails, rather than blocking the push due to an unrelated API error.

### Permission Computation

1. The permission compiler **MUST** automatically add `administration: read` to the computed GitHub App token permissions when `check-branch-protection` is `true` (or absent/defaulted).
2. The permission compiler **MUST NOT** add `administration: read` when `check-branch-protection` is explicitly `false`.
3. Implementations **SHOULD** document that `administration: read` is a GitHub App-only scope with no effect on standard `GITHUB_TOKEN`.

### Schema

1. The JSON schema for `push-to-pull-request-branch` **MUST** declare `check-branch-protection` as an optional boolean property with `default: true`.
2. The schema **MUST NOT** require `check-branch-protection` to be present.

### Conformance

An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance.

---

*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/24917291168) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*
7 changes: 7 additions & 0 deletions docs/src/content/docs/reference/frontmatter-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -4843,6 +4843,13 @@ safe-outputs:
# (optional)
allow-workflows: true

# When false, skips the branch protection API pre-flight check before pushing. Set
# to false to avoid requiring administration: read permission. The GitHub platform
# will still enforce branch protection at push time. Default is true (check
# enabled).
# (optional)
check-branch-protection: true

# Enable AI agents to minimize (hide) comments on issues or pull requests based on
# relevance, spam detection, or moderation rules.
# (optional)
Expand Down
Loading