diff --git a/actions/setup-cli/install.sh b/actions/setup-cli/install.sh
index c7a5ed2ffed..1c565c9e893 100755
--- a/actions/setup-cli/install.sh
+++ b/actions/setup-cli/install.sh
@@ -1,7 +1,7 @@
#!/bin/bash
set +o histexpand
-# Kept in sync with actions/setup-cli/install.sh — edit this file, then copy to that path.
+# Script sync note: install-gh-aw.sh is canonical. actions/setup-cli/install.sh is copied from install-gh-aw.sh.
# Script to download and install gh-aw binary for the current OS and architecture
# Supports: Linux, macOS (Darwin), FreeBSD, Windows (Git Bash/MSYS/Cygwin)
diff --git a/actions/setup/js/extract_base_branch_from_agent_output.cjs b/actions/setup/js/extract_base_branch_from_agent_output.cjs
index c7165de0c31..84c737b2618 100644
--- a/actions/setup/js/extract_base_branch_from_agent_output.cjs
+++ b/actions/setup/js/extract_base_branch_from_agent_output.cjs
@@ -2,9 +2,10 @@
///
const fs = require("fs");
+const { spawnSync } = require("child_process");
const AGENT_OUTPUT_PATH = "/tmp/gh-aw/agent_output.json";
-const SAFE_BRANCH_NAME_REGEX = /^[a-zA-Z0-9/_.-]+$/;
+const MAX_BRANCH_NAME_LENGTH = 255;
/**
* @param {string} itemRepo
@@ -49,9 +50,26 @@ function extractBaseBranchFromAgentOutput(opts = {}) {
async function main() {
const baseBranch = extractBaseBranchFromAgentOutput();
if (!baseBranch) return;
- if (!SAFE_BRANCH_NAME_REGEX.test(baseBranch) || baseBranch.length > 255) return;
+ if (!isValidBaseBranchName(baseBranch)) return;
core.setOutput("base-branch", baseBranch);
core.info(`Extracted base branch from safe output: ${baseBranch}`);
}
-module.exports = { extractBaseBranchFromAgentOutput, isSameWorkflowRepo, main };
+/**
+ * @param {string} branchName
+ * @returns {boolean}
+ */
+function isValidBaseBranchName(branchName) {
+ if (!branchName || branchName.length > MAX_BRANCH_NAME_LENGTH) {
+ return false;
+ }
+
+ // Use refs/heads/ to validate as a literal ref, not a branch expression.
+ // --branch also accepts @{-N} git expressions; refs/heads/ form correctly rejects them.
+ // Fail-closed: if git is unavailable (ENOENT) or times out (ETIMEDOUT), result.error is set
+ // and we return false, safely dropping the base branch rather than passing an invalid value.
+ const result = spawnSync("git", ["check-ref-format", `refs/heads/${branchName}`], { stdio: "ignore", timeout: 5000 });
+ return !result.error && result.status === 0;
+}
+
+module.exports = { extractBaseBranchFromAgentOutput, isSameWorkflowRepo, isValidBaseBranchName, main };
diff --git a/actions/setup/js/extract_base_branch_from_agent_output.test.cjs b/actions/setup/js/extract_base_branch_from_agent_output.test.cjs
index 91e33791053..e4523bafa01 100644
--- a/actions/setup/js/extract_base_branch_from_agent_output.test.cjs
+++ b/actions/setup/js/extract_base_branch_from_agent_output.test.cjs
@@ -2,7 +2,7 @@
import { describe, it, expect } from "vitest";
import fs from "fs";
import path from "path";
-import { extractBaseBranchFromAgentOutput, isSameWorkflowRepo } from "./extract_base_branch_from_agent_output.cjs";
+import { extractBaseBranchFromAgentOutput, isSameWorkflowRepo, isValidBaseBranchName } from "./extract_base_branch_from_agent_output.cjs";
describe("extract_base_branch_from_agent_output", () => {
it("matches fully-qualified repos", () => {
@@ -70,4 +70,28 @@ describe("extract_base_branch_from_agent_output", () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
+
+ it("accepts valid git branch names used in safe outputs", () => {
+ expect(isValidBaseBranchName("feature/x")).toBe(true);
+ expect(isValidBaseBranchName("release/v1.2+hotfix")).toBe(true);
+ });
+
+ it("rejects invalid git branch names even if they look regex-safe", () => {
+ expect(isValidBaseBranchName("foo..bar")).toBe(false);
+ expect(isValidBaseBranchName("main.lock")).toBe(false);
+ expect(isValidBaseBranchName(".foo")).toBe(false);
+ expect(isValidBaseBranchName("foo/.bar")).toBe(false);
+ });
+
+ it("rejects git branch expressions (@{-N} notation)", () => {
+ expect(isValidBaseBranchName("@{-1}")).toBe(false);
+ expect(isValidBaseBranchName("@{-2}")).toBe(false);
+ });
+
+ it("enforces the 255-character length limit", () => {
+ const atLimit = "a".repeat(255);
+ const overLimit = "a".repeat(256);
+ expect(isValidBaseBranchName(atLimit)).toBe(true);
+ expect(isValidBaseBranchName(overLimit)).toBe(false);
+ });
});
diff --git a/docs/adr/30071-decouple-safe-outputs-base-branch-from-event-context.md b/docs/adr/30071-decouple-safe-outputs-base-branch-from-event-context.md
index b108aba02bd..0551c458011 100644
--- a/docs/adr/30071-decouple-safe-outputs-base-branch-from-event-context.md
+++ b/docs/adr/30071-decouple-safe-outputs-base-branch-from-event-context.md
@@ -56,7 +56,7 @@ Insert a `gh api` call into the workflow itself (before checkout) to fetch the P
### Workflow Extraction Step
1. Every compiled workflow job that performs a safe-outputs checkout **MUST** include an `extract-base-branch` step that runs after the agent artifact download and before the checkout step.
-2. The extraction step **MUST** validate the extracted branch name against the pattern `^[a-zA-Z0-9/_.-]+$` and enforce a maximum length of 255 characters before writing to `GITHUB_OUTPUT`.
+2. The extraction step **MUST** validate the extracted branch name using `git check-ref-format --branch` semantics and enforce a maximum length of 255 characters before writing to `GITHUB_OUTPUT`.
3. The extraction step **MUST NOT** fail the workflow if `agent_output.json` is absent or if no matching safe-output entry contains `base_branch`; it **MUST** exit successfully (silently) in those cases.
4. Checkout steps **MUST** lead the `ref` expression with `steps.extract-base-branch.outputs.base-branch` and **SHOULD** retain event-context fallbacks (`github.base_ref`, `github.event.pull_request.base.ref`, etc.) for backward compatibility.