diff --git a/.github/workflows/cli-consistency-checker.lock.yml b/.github/workflows/cli-consistency-checker.lock.yml index c622fb5cccf..090c080c9c8 100644 --- a/.github/workflows/cli-consistency-checker.lock.yml +++ b/.github/workflows/cli-consistency-checker.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"40925dafe7f07884fa18d348d198d2799520b08785756b2a1e12c921735d15b4","body_hash":"c37885bacb425ffb50bd5d957c98e916b75dc01a26200c4232cf780a2d21a713","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.70","copilot-sdk":"1.0.6"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"40925dafe7f07884fa18d348d198d2799520b08785756b2a1e12c921735d15b4","body_hash":"c37885bacb425ffb50bd5d957c98e916b75dc01a26200c4232cf780a2d21a713","agent_id":"copilot","engine_versions":{"copilot":"1.0.70","copilot-sdk":"1.0.6"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.35","digest":"sha256:2202f63e8650b2b8b0d38033b44a05387b2b71ad3e690c4d23a34786f5462aed","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.35@sha256:2202f63e8650b2b8b0d38033b44a05387b2b71ad3e690c4d23a34786f5462aed"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.35","digest":"sha256:755b79d0dfda82bd6b43a208d68666721e504110c5d342a4eeb199802644ff04","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.35@sha256:755b79d0dfda82bd6b43a208d68666721e504110c5d342a4eeb199802644ff04"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.35","digest":"sha256:f69282ec7b1326ba53891c399cf5b10475c0d3ccf4e1519b33d234a5427b57d3","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.35@sha256:f69282ec7b1326ba53891c399cf5b10475c0d3ccf4e1519b33d234a5427b57d3"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.1","digest":"sha256:ad2a979c2cd8b50098e84938ca9c9c1580eb8e91526f101a90adfba7859b2c32","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.1@sha256:ad2a979c2cd8b50098e84938ca9c9c1580eb8e91526f101a90adfba7859b2c32"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.6.0","digest":"sha256:2b0c48b070f61e9d3969269ead600f62d00fb237b60ac849ef3d166ee7de9ad3","pinned_image":"ghcr.io/github/github-mcp-server:v1.6.0@sha256:2b0c48b070f61e9d3969269ead600f62d00fb237b60ac849ef3d166ee7de9ad3"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -149,7 +149,7 @@ jobs: GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_INFO_FRONTMATTER_EMOJI: "✅" - GH_AW_COMPILED_STRICT: "true" + GH_AW_COMPILED_STRICT: "false" GH_AW_INFO_FEATURES: '{"gh-aw-detection":true}' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: @@ -158,6 +158,11 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); await main(core, context); + - name: Enforce strict mode policy + if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }} + run: | + echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." + exit 1 - name: Restore daily AIC usage cache id: restore-daily-aic-cache if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 1567d7f2799..785cac2c55b 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -30,6 +30,7 @@ const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { checkFileProtection, checkFileProtectionPostApply } = require("./manifest_file_helpers.cjs"); const { renderTemplateFromFile, renderFilesList, buildProtectedFileList, getPromptPath } = require("./messages_core.cjs"); +const { overridePersistedExtraheader, restorePersistedExtraheader } = require("./git_auth_helpers.cjs"); const { COPILOT_REVIEWER_BOT, FAQ_CREATE_PR_PERMISSIONS_URL } = require("./constants.cjs"); const { isStagedMode } = require("./safe_output_helpers.cjs"); const { normalizeCommitSHA } = require("./commit_sha_helpers.cjs"); @@ -563,12 +564,33 @@ function enforcePullRequestLimits(patchContent, maxFiles = MAX_FILES) { * existing remote ref when recreate-ref is enabled. * @param {string} [options.owner] - Repository owner for the deleteRef call. * @param {string} [options.repo] - Repository name for the deleteRef call. + * @param {string} [options.remoteTarget] - Remote name or URL used for remote branch existence checks. + * @param {string} [options.remoteToken] - Optional token used for authenticated remote branch checks. * @returns {Promise} The (possibly renamed) branch name to use going forward. */ async function handleRemoteBranchCollision(branchName, preserveBranchName, options = {}) { let remoteBranchExists = false; try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); + const remoteTarget = options.remoteTarget || "origin"; + const checkRemoteBranch = async () => exec.getExecOutput("git", ["ls-remote", "--heads", remoteTarget, branchName]); + let checkResult; + if (options.remoteToken) { + const githubServerUrl = (process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, ""); + let previousExtraheaders = []; + let overrideApplied = false; + try { + previousExtraheaders = await overridePersistedExtraheader(githubServerUrl, options.remoteToken); + overrideApplied = true; + checkResult = await checkRemoteBranch(); + } finally { + if (overrideApplied) { + await restorePersistedExtraheader(githubServerUrl, previousExtraheaders); + } + } + } else { + checkResult = await checkRemoteBranch(); + } + const { stdout } = checkResult; if (stdout.trim()) { remoteBranchExists = true; } @@ -753,6 +775,8 @@ async function main(config = {}) { // Base branch from config (if set) - validated at factory level if explicit // Dynamic base branch resolution happens per-message after resolving the actual target repo const configBaseBranch = config.base_branch || null; + const configuredHeadRepo = typeof config["head-repo"] === "string" ? config["head-repo"].trim() : ""; + const headGitHubToken = typeof config["head-github-token"] === "string" ? config["head-github-token"].trim() : ""; // SECURITY: If base branch is explicitly configured, validate it at factory level if (configBaseBranch) { @@ -788,6 +812,9 @@ async function main(config = {}) { core.info(`Base branch: ${configBaseBranch || "(dynamic - resolved per target repo)"}`); core.info(`Default target repo: ${defaultTargetRepo}`); + if (configuredHeadRepo) { + core.info(`Configured head repo: ${configuredHeadRepo}`); + } if (allowedRepos.size > 0) { core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); } @@ -904,6 +931,23 @@ async function main(config = {}) { } const { repo: itemRepo, repoParts } = repoResult; core.info(`Target repository: ${itemRepo}`); + let pushRepo = itemRepo; + let pushRepoParts = repoParts; + let pushGithubClient = githubClient; + if (configuredHeadRepo) { + const headRepoResult = resolveAndValidateRepo({ repo: configuredHeadRepo }, itemRepo, allowedRepos, "pull request head repository"); + if (!headRepoResult.success) { + return { success: false, error: headRepoResult.error }; + } + pushRepo = headRepoResult.repo; + pushRepoParts = headRepoResult.repoParts; + if (headGitHubToken && typeof global.getOctokit === "function") { + pushGithubClient = global.getOctokit(headGitHubToken); + } + core.info(`Resolved head repository: ${pushRepo}`); + } + const pushRemoteUrl = pushRepo.toLowerCase() === itemRepo.toLowerCase() ? "" : `${(process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, "")}/${pushRepo}.git`; + const getPullRequestHeadRef = branch => (pushRepo.toLowerCase() === itemRepo.toLowerCase() ? branch : `${pushRepoParts.owner}:${branch}`); // Resolve base branch for this target repository // Use config value if set, otherwise resolve dynamically for the specific target repo @@ -1585,15 +1629,24 @@ async function main(config = {}) { // the catch block below. { try { - branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient, owner: repoParts.owner, repo: repoParts.repo }); + branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { + recreateRef, + githubClient: pushGithubClient, + owner: pushRepoParts.owner, + repo: pushRepoParts.repo, + remoteTarget: pushRemoteUrl || "origin", + remoteToken: headGitHubToken, + }); await pushSignedCommits({ - githubClient, - owner: repoParts.owner, - repo: repoParts.repo, + githubClient: pushGithubClient, + owner: pushRepoParts.owner, + repo: pushRepoParts.repo, branch: branchName, baseRef: `origin/${baseBranch}`, cwd: process.cwd(), + pushRemoteUrl, + pushToken: headGitHubToken, signedCommits, resolvedTemporaryIds, currentRepo: itemRepo, @@ -1621,12 +1674,14 @@ async function main(config = {}) { try { await rewriteBundleBranchAsSingleCommit(baseBranch, exec); await pushSignedCommits({ - githubClient, - owner: repoParts.owner, - repo: repoParts.repo, + githubClient: pushGithubClient, + owner: pushRepoParts.owner, + repo: pushRepoParts.repo, branch: branchName, baseRef: `origin/${baseBranch}`, cwd: process.cwd(), + pushRemoteUrl, + pushToken: headGitHubToken, signedCommits, resolvedTemporaryIds, currentRepo: itemRepo, @@ -1701,10 +1756,10 @@ git reset --hard git update-ref -d ${fallbackBundleTempRef} # Push the branch to origin -git push origin ${branchName} +git push ${pushRemoteUrl || "origin"} ${branchName} # Create the pull request -gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo ${repoParts.owner}/${repoParts.repo} +gh pr create --title '${title}' --base ${baseBranch} --head ${getPullRequestHeadRef(branchName)} --repo ${repoParts.owner}/${repoParts.repo} \`\`\``; try { @@ -1956,15 +2011,24 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo // the catch block below. { try { - branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient, owner: repoParts.owner, repo: repoParts.repo }); + branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { + recreateRef, + githubClient: pushGithubClient, + owner: pushRepoParts.owner, + repo: pushRepoParts.repo, + remoteTarget: pushRemoteUrl || "origin", + remoteToken: headGitHubToken, + }); await pushSignedCommits({ - githubClient, - owner: repoParts.owner, - repo: repoParts.repo, + githubClient: pushGithubClient, + owner: pushRepoParts.owner, + repo: pushRepoParts.repo, branch: branchName, baseRef: `origin/${baseBranch}`, cwd: process.cwd(), + pushRemoteUrl, + pushToken: headGitHubToken, signedCommits, resolvedTemporaryIds, currentRepo: itemRepo, @@ -2045,10 +2109,10 @@ git checkout -b ${branchName} git am --3way /tmp/agent-${runId}/${patchFileName} # Push the branch to origin -git push origin ${branchName} +git push ${pushRemoteUrl || "origin"} ${branchName} # Create the pull request -gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo ${repoParts.owner}/${repoParts.repo} +gh pr create --title '${title}' --base ${baseBranch} --head ${getPullRequestHeadRef(branchName)} --repo ${repoParts.owner}/${repoParts.repo} \`\`\` ${patchPreview}`; @@ -2086,6 +2150,7 @@ ${patchPreview}`; issue_url: issue.html_url, branch_name: branchName, repo: itemRepo, + head_repo: pushRepo, }; } catch (issueError) { const error = `Failed to push and failed to create fallback issue. Push error: ${getErrorMessage(pushError)}. Issue error: ${getErrorMessage(issueError)}`; @@ -2110,15 +2175,24 @@ ${patchPreview}`; await exec.exec(`git commit --allow-empty -m "Initialize"`); core.info("Created empty commit"); - branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient, owner: repoParts.owner, repo: repoParts.repo }); + branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { + recreateRef, + githubClient: pushGithubClient, + owner: pushRepoParts.owner, + repo: pushRepoParts.repo, + remoteTarget: pushRemoteUrl || "origin", + remoteToken: headGitHubToken, + }); await pushSignedCommits({ - githubClient, - owner: repoParts.owner, - repo: repoParts.repo, + githubClient: pushGithubClient, + owner: pushRepoParts.owner, + repo: pushRepoParts.repo, branch: branchName, baseRef: `origin/${baseBranch}`, cwd: process.cwd(), + pushRemoteUrl, + pushToken: headGitHubToken, signedCommits, resolvedTemporaryIds, currentRepo: itemRepo, @@ -2200,7 +2274,7 @@ ${patchPreview}`; }); } else { // Normal case — push succeeded, provide compare URL. - const createPrUrl = buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, branchName, title); + const createPrUrl = buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, branchName, title, undefined, getPullRequestHeadRef(branchName)); fallbackBody = renderManifestProtectionFallbackBody(issueSafeMainBodyContent, footerContent, fileList, createPrUrl); } @@ -2211,7 +2285,7 @@ ${patchPreview}`; if (!manifestProtectionPushFailedError) { try { - const createPrUrl = buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, branchName, title, issue.number); + const createPrUrl = buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, branchName, title, issue.number, getPullRequestHeadRef(branchName)); const fallbackBodyWithCloseKeyword = renderManifestProtectionFallbackBody(issueSafeMainBodyContent, footerContent, fileList, createPrUrl); await withRetry( @@ -2241,6 +2315,7 @@ ${patchPreview}`; issue_url: issue.html_url, branch_name: branchName, repo: itemRepo, + head_repo: pushRepo, }; } catch (issueError) { const error = `Protected file protection: failed to create review issue. Error: ${getErrorMessage(issueError)}`; @@ -2258,7 +2333,7 @@ ${patchPreview}`; repo: repoParts.repo, title: title, body: body, - head: branchName, + head: getPullRequestHeadRef(branchName), base: baseBranch, draft: draft, }), @@ -2474,8 +2549,8 @@ ${patchPreview}`; // multi-commit branches where workflow files may have been iteratively modified. const ciTriggerResult = await pushExtraEmptyCommit({ branchName, - repoOwner: repoParts.owner, - repoName: repoParts.repo, + repoOwner: pushRepoParts.owner, + repoName: pushRepoParts.repo, newCommitCount, }); if (ciTriggerResult.success && !ciTriggerResult.skipped) { @@ -2491,6 +2566,7 @@ ${patchPreview}`; branch_name: branchName, temporaryId: temporaryId, repo: itemRepo, + head_repo: pushRepo, }; } catch (prError) { const errorMessage = getErrorMessage(prError); @@ -2504,7 +2580,7 @@ ${patchPreview}`; const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; // Encode branch name path segments individually to preserve '/' while encoding other special characters const encodedBase = baseBranch.split("/").map(encodeURIComponent).join("/"); - const encodedHead = branchName.split("/").map(encodeURIComponent).join("/"); + const encodedHead = getPullRequestHeadRef(branchName).split("/").map(encodeURIComponent).join("/"); const createPrUrl = `${githubServer}/${repoParts.owner}/${repoParts.repo}/compare/${encodedBase}...${encodedHead}?expand=1&title=${encodeURIComponent(title)}`; // Read patch content for preview @@ -2538,6 +2614,7 @@ ${patchPreview}`; issue_url: issue.html_url, branch_name: branchName, repo: itemRepo, + head_repo: pushRepo, }; } catch (issueError) { const error = `Failed to create pull request (permission denied) and failed to create fallback issue. PR error: ${errorMessage}. Issue error: ${getErrorMessage(issueError)}`; @@ -2564,7 +2641,7 @@ ${patchPreview}`; // Create issue as fallback with enhanced body content const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${repoParts.owner}/${repoParts.repo}/tree/${branchName}`; + const branchUrl = `${githubServer}/${pushRepoParts.owner}/${pushRepoParts.repo}/tree/${branchName}`; // Read patch content for preview let patchPreview = ""; @@ -2585,7 +2662,7 @@ ${patchPreview}`; To create the pull request manually: \`\`\`sh -gh pr create --title "${title}" --base ${baseBranch} --head ${branchName} --repo ${repoParts.owner}/${repoParts.repo} +gh pr create --title "${title}" --base ${baseBranch} --head ${getPullRequestHeadRef(branchName)} --repo ${repoParts.owner}/${repoParts.repo} \`\`\` ${patchPreview}`; @@ -2608,6 +2685,7 @@ ${patchPreview}`; issue_url: issue.html_url, branch_name: branchName, repo: itemRepo, + head_repo: pushRepo, }; } catch (issueError) { const error = `Failed to create both pull request and fallback issue. PR error: ${errorMessage}. Issue error: ${getErrorMessage(issueError)}`; diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index c1065d46e2b..8f94d46f9d5 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -7,6 +7,7 @@ import * as path from "path"; import * as os from "os"; const require = createRequire(import.meta.url); +const promptsSourceDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../md"); const { getPatchPathForBranch, getPatchPathForBranchInRepo } = require("./git_patch_utils.cjs"); const { getBundlePathForBranch, getBundlePathForBranchInRepo } = require("./generate_git_bundle.cjs"); @@ -41,8 +42,19 @@ function cleanupCanonicalTransports() { createdTransportPaths.clear(); } +function copyPromptTemplate(promptsDir, templateName) { + fs.copyFileSync(path.join(promptsSourceDir, templateName), path.join(promptsDir, templateName)); +} + +function ensureDefaultDisclosureHeaderPrompt() { + const promptsDir = path.join(process.env.RUNNER_TEMP || os.tmpdir(), "gh-aw", "prompts"); + fs.mkdirSync(promptsDir, { recursive: true }); + copyPromptTemplate(promptsDir, "safe_outputs_disclosure_header.md"); +} + beforeEach(() => { cleanupCanonicalTransports(); + ensureDefaultDisclosureHeaderPrompt(); }); afterEach(() => { cleanupCanonicalTransports(); @@ -1674,12 +1686,10 @@ describe("create_pull_request - allowed-files strict allowlist", () => { pushSignedSpy = vi.spyOn(pushSignedCommitsModule, "pushSignedCommits").mockResolvedValue("bundle-tip"); const promptsDir = path.join(tempDir, "prompts"); fs.mkdirSync(promptsDir, { recursive: true }); - const requestReviewTemplateSrc = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../md/manifest_protection_request_review.md"); - fs.copyFileSync(requestReviewTemplateSrc, path.join(promptsDir, "manifest_protection_request_review.md")); - const requestChangesTemplateSrc = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../md/manifest_protection_request_changes_review.md"); - fs.copyFileSync(requestChangesTemplateSrc, path.join(promptsDir, "manifest_protection_request_changes_review.md")); - const threatWarningTemplateSrc = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../md/threat_warning_request_changes_review.md"); - fs.copyFileSync(threatWarningTemplateSrc, path.join(promptsDir, "threat_warning_request_changes_review.md")); + copyPromptTemplate(promptsDir, "manifest_protection_request_review.md"); + copyPromptTemplate(promptsDir, "manifest_protection_request_changes_review.md"); + copyPromptTemplate(promptsDir, "threat_warning_request_changes_review.md"); + copyPromptTemplate(promptsDir, "safe_outputs_disclosure_header.md"); process.env.GH_AW_PROMPTS_DIR = promptsDir; // Clear module cache so globals are picked up fresh @@ -1882,10 +1892,9 @@ ${diffs} const patchPath = writePatch("feature/protected", createPatchWithFiles(".github/aw/instructions.md")); const promptsDir = path.join(tempDir, "prompts"); fs.mkdirSync(promptsDir, { recursive: true }); - const templateSrc = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../md/manifest_protection_create_pr_fallback.md"); - fs.copyFileSync(templateSrc, path.join(promptsDir, "manifest_protection_create_pr_fallback.md")); - const pushFailedTemplateSrc = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../md/manifest_protection_push_failed_fallback.md"); - fs.copyFileSync(pushFailedTemplateSrc, path.join(promptsDir, "manifest_protection_push_failed_fallback.md")); + copyPromptTemplate(promptsDir, "manifest_protection_create_pr_fallback.md"); + copyPromptTemplate(promptsDir, "manifest_protection_push_failed_fallback.md"); + copyPromptTemplate(promptsDir, "safe_outputs_disclosure_header.md"); process.env.GH_AW_PROMPTS_DIR = promptsDir; global.github.rest.issues = { @@ -1922,10 +1931,9 @@ ${diffs} fs.writeFileSync(bundlePath, "bundle content"); const promptsDir = path.join(tempDir, "prompts"); fs.mkdirSync(promptsDir, { recursive: true }); - const templateSrc = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../md/manifest_protection_create_pr_fallback.md"); - fs.copyFileSync(templateSrc, path.join(promptsDir, "manifest_protection_create_pr_fallback.md")); - const pushFailedTemplateSrc = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../md/manifest_protection_push_failed_fallback.md"); - fs.copyFileSync(pushFailedTemplateSrc, path.join(promptsDir, "manifest_protection_push_failed_fallback.md")); + copyPromptTemplate(promptsDir, "manifest_protection_create_pr_fallback.md"); + copyPromptTemplate(promptsDir, "manifest_protection_push_failed_fallback.md"); + copyPromptTemplate(promptsDir, "safe_outputs_disclosure_header.md"); process.env.GH_AW_PROMPTS_DIR = promptsDir; global.github.rest.issues = { @@ -2851,6 +2859,7 @@ describe("create_pull_request - patch apply fallback to original base commit", ( fs.mkdirSync(promptsDir, { recursive: true }); fs.writeFileSync(path.join(promptsDir, "manifest_protection_request_review.md"), "Protected files: {{files}}", "utf8"); fs.writeFileSync(path.join(promptsDir, "manifest_protection_request_changes_review.md"), "Protected files: {{files}}", "utf8"); + copyPromptTemplate(promptsDir, "safe_outputs_disclosure_header.md"); process.env.GH_AW_PROMPTS_DIR = promptsDir; global.exec = { exec: vi.fn().mockImplementation((cmd, args) => { @@ -3052,14 +3061,14 @@ describe("create_pull_request - patch apply fallback to original base commit", ( let renameCalled = false; global.exec = { exec: vi.fn().mockImplementation((cmd, args) => { - const cmdStr = typeof cmd === "string" ? cmd : `${cmd} ${(args || []).join(" ")}`; + const cmdStr = `${cmd} ${(args || []).join(" ")}`; if (cmdStr.includes("git branch -m")) { renameCalled = true; } return Promise.resolve(0); }), getExecOutput: vi.fn().mockImplementation((cmd, args) => { - const cmdStr = typeof cmd === "string" ? cmd : `${cmd} ${(args || []).join(" ")}`; + const cmdStr = `${cmd} ${(args || []).join(" ")}`; if (cmdStr.includes("ls-remote --heads origin")) { return Promise.resolve({ exitCode: 0, stdout: "abc123\trefs/heads/preserve-me\n", stderr: "" }); } @@ -3092,7 +3101,7 @@ describe("create_pull_request - patch apply fallback to original base commit", ( global.exec = { exec: vi.fn().mockResolvedValue(0), getExecOutput: vi.fn().mockImplementation((cmd, args) => { - const cmdStr = typeof cmd === "string" ? cmd : `${cmd} ${(args || []).join(" ")}`; + const cmdStr = `${cmd} ${(args || []).join(" ")}`; if (cmdStr.includes("ls-remote --heads origin")) { return Promise.resolve({ exitCode: 0, stdout: "abc123\trefs/heads/preserve-me\n", stderr: "" }); } @@ -3117,7 +3126,7 @@ describe("create_pull_request - patch apply fallback to original base commit", ( global.exec = { exec: vi.fn().mockResolvedValue(0), getExecOutput: vi.fn().mockImplementation((cmd, args) => { - const cmdStr = typeof cmd === "string" ? cmd : `${cmd} ${(args || []).join(" ")}`; + const cmdStr = `${cmd} ${(args || []).join(" ")}`; if (cmdStr.includes("ls-remote --heads origin")) { return Promise.resolve({ exitCode: 0, stdout: "abc123\trefs/heads/preserve-me\n", stderr: "" }); } @@ -3142,7 +3151,7 @@ describe("create_pull_request - patch apply fallback to original base commit", ( let capturedRenamedBranch = null; global.exec = { exec: vi.fn().mockImplementation((cmd, args) => { - const cmdStr = typeof cmd === "string" ? cmd : `${cmd} ${(args || []).join(" ")}`; + const cmdStr = `${cmd} ${(args || []).join(" ")}`; // Capture the new branch name from: git branch -m const renameMatch = cmdStr.match(/git branch -m \S+ (\S+)/); if (renameMatch) { @@ -3151,7 +3160,7 @@ describe("create_pull_request - patch apply fallback to original base commit", ( return Promise.resolve(0); }), getExecOutput: vi.fn().mockImplementation((cmd, args) => { - const cmdStr = typeof cmd === "string" ? cmd : `${cmd} ${(args || []).join(" ")}`; + const cmdStr = `${cmd} ${(args || []).join(" ")}`; if (cmdStr.includes("ls-remote --heads origin")) { return Promise.resolve({ exitCode: 0, stdout: "abc123\trefs/heads/chaos/preserve-me\n", stderr: "" }); } @@ -3196,14 +3205,14 @@ describe("create_pull_request - patch apply fallback to original base commit", ( let renameCalled = false; global.exec = { exec: vi.fn().mockImplementation((cmd, args) => { - const cmdStr = typeof cmd === "string" ? cmd : `${cmd} ${(args || []).join(" ")}`; + const cmdStr = `${cmd} ${(args || []).join(" ")}`; if (cmdStr.includes("git branch -m")) { renameCalled = true; } return Promise.resolve(0); }), getExecOutput: vi.fn().mockImplementation((cmd, args) => { - const cmdStr = typeof cmd === "string" ? cmd : `${cmd} ${(args || []).join(" ")}`; + const cmdStr = `${cmd} ${(args || []).join(" ")}`; if (cmdStr.includes("ls-remote --heads origin")) { return Promise.resolve({ exitCode: 0, stdout: "abc123\trefs/heads/some-branch\n", stderr: "" }); } @@ -3393,12 +3402,10 @@ describe("create_pull_request - threat detection caution", () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "create-pr-threat-test-")); const promptsDir = path.join(tempDir, "prompts"); fs.mkdirSync(promptsDir, { recursive: true }); - const requestReviewTemplateSrc = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../md/manifest_protection_request_review.md"); - fs.copyFileSync(requestReviewTemplateSrc, path.join(promptsDir, "manifest_protection_request_review.md")); - const requestChangesTemplateSrc = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../md/manifest_protection_request_changes_review.md"); - fs.copyFileSync(requestChangesTemplateSrc, path.join(promptsDir, "manifest_protection_request_changes_review.md")); - const threatWarningTemplateSrc = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../md/threat_warning_request_changes_review.md"); - fs.copyFileSync(threatWarningTemplateSrc, path.join(promptsDir, "threat_warning_request_changes_review.md")); + copyPromptTemplate(promptsDir, "manifest_protection_request_review.md"); + copyPromptTemplate(promptsDir, "manifest_protection_request_changes_review.md"); + copyPromptTemplate(promptsDir, "threat_warning_request_changes_review.md"); + copyPromptTemplate(promptsDir, "safe_outputs_disclosure_header.md"); process.env.GH_AW_PROMPTS_DIR = promptsDir; global.core = { @@ -4122,6 +4129,22 @@ describe("create_pull_request - branch-prefix config", () => { expect(branchArg).not.toContain("signed/"); }); + it("should use an owner-qualified PR head when head-repo differs from target-repo", async () => { + const { main } = require("./create_pull_request.cjs"); + const handler = await main({ + allow_empty: true, + "head-repo": "fork-owner/test-repo", + allowed_repos: ["test-owner/test-repo", "fork-owner/test-repo"], + }); + + await handler({ title: "Test PR", body: "body", branch: "my-feature" }, {}); + + const createCall = global.github.rest.pulls.create.mock.calls[0][0]; + expect(createCall.owner).toBe("test-owner"); + expect(createCall.repo).toBe("test-repo"); + expect(createCall.head).toMatch(/^fork-owner:my-feature(?:-[0-9a-f]+)?$/); + }); + it("should normalize an invalid branch-prefix and emit a warning", async () => { const { main } = require("./create_pull_request.cjs"); const handler = await main({ branch_prefix: "bad prefix: ", allow_empty: true }); @@ -4149,8 +4172,8 @@ describe("create_pull_request - E003 file-limit fallback-to-issue", () => { // Set up prompts directory with the E003 template so getPromptPath resolves const promptsDir = path.join(tempDir, "prompts"); fs.mkdirSync(promptsDir, { recursive: true }); - const templateSrc = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../md/e003_file_limit_fallback.md"); - fs.copyFileSync(templateSrc, path.join(promptsDir, "e003_file_limit_fallback.md")); + copyPromptTemplate(promptsDir, "e003_file_limit_fallback.md"); + copyPromptTemplate(promptsDir, "safe_outputs_disclosure_header.md"); process.env.GH_AW_PROMPTS_DIR = promptsDir; global.core = { diff --git a/actions/setup/js/create_pull_request_helpers.cjs b/actions/setup/js/create_pull_request_helpers.cjs index a634f146634..a00b00e3700 100644 --- a/actions/setup/js/create_pull_request_helpers.cjs +++ b/actions/setup/js/create_pull_request_helpers.cjs @@ -223,11 +223,12 @@ function generatePatchPreview(patchContent) { * @param {string} branchName * @param {string} title * @param {number} [fallbackIssueNumber] + * @param {string} [headRef] * @returns {string} */ -function buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, branchName, title, fallbackIssueNumber) { +function buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, branchName, title, fallbackIssueNumber, headRef) { const encodedBase = encodePathSegments(baseBranch); - const encodedHead = encodePathSegments(branchName); + const encodedHead = encodePathSegments(headRef || branchName); let createPrUrl = `${githubServer}/${repoParts.owner}/${repoParts.repo}/compare/${encodedBase}...${encodedHead}?expand=1&title=${encodeURIComponent(title)}`; if (typeof fallbackIssueNumber === "number") { createPrUrl += `&body=${encodeURIComponent(`Closes #${fallbackIssueNumber}`)}`; diff --git a/actions/setup/js/git_auth_helpers.cjs b/actions/setup/js/git_auth_helpers.cjs index 1d2c8701efd..7e1ee292b70 100644 --- a/actions/setup/js/git_auth_helpers.cjs +++ b/actions/setup/js/git_auth_helpers.cjs @@ -22,14 +22,13 @@ function normalizeServerUrl(serverUrl) { * Returns an empty array when the key is absent (exit code ≠ 0). * * @param {string} serverUrl + * @param {string} [cwd] - Optional working directory for the git config command * @returns {Promise} */ -async function getExtraheaderValues(serverUrl) { +async function getExtraheaderValues(serverUrl, cwd) { const normalizedUrl = normalizeServerUrl(serverUrl); - const result = await exec.getExecOutput("git", ["config", "--get-all", `http.${normalizedUrl}/.extraheader`], { - silent: true, - ignoreReturnCode: true, - }); + const execOptions = cwd ? { silent: true, ignoreReturnCode: true, cwd } : { silent: true, ignoreReturnCode: true }; + const result = await exec.getExecOutput("git", ["config", "--get-all", `http.${normalizedUrl}/.extraheader`], execOptions); if (result.exitCode !== 0 || !result.stdout.trim()) { return []; } @@ -61,13 +60,14 @@ async function checkoutHasPersistedExtraheader(serverUrl) { * * @param {string} serverUrl * @param {string} token + * @param {string} [cwd] - Optional working directory for the git config command * @returns {Promise} */ -async function overridePersistedExtraheader(serverUrl, token) { +async function overridePersistedExtraheader(serverUrl, token, cwd) { const normalizedUrl = normalizeServerUrl(serverUrl); let previousValues; try { - previousValues = await getExtraheaderValues(serverUrl); + previousValues = await getExtraheaderValues(serverUrl, cwd); core.info(`git_auth_helpers: read ${previousValues.length} existing extraheader value(s) for ${normalizedUrl}`); } catch (err) { core.warning(`git_auth_helpers: could not read existing extraheader values — restoration will proceed with empty defaults: ${getErrorMessage(err)}`); @@ -75,7 +75,11 @@ async function overridePersistedExtraheader(serverUrl, token) { } core.info(`git_auth_helpers: overriding http.${normalizedUrl}/.extraheader with CI trigger token`); const tokenBase64 = Buffer.from(`x-access-token:${token.trim()}`).toString("base64"); - await exec.exec("git", ["config", "--replace-all", `http.${normalizedUrl}/.extraheader`, `Authorization: basic ${tokenBase64}`]); + if (cwd) { + await exec.exec("git", ["config", "--replace-all", `http.${normalizedUrl}/.extraheader`, `Authorization: basic ${tokenBase64}`], { cwd }); + } else { + await exec.exec("git", ["config", "--replace-all", `http.${normalizedUrl}/.extraheader`, `Authorization: basic ${tokenBase64}`]); + } core.info(`git_auth_helpers: extraheader override applied`); return previousValues; } @@ -85,14 +89,19 @@ async function overridePersistedExtraheader(serverUrl, token) { * * @param {string} serverUrl * @param {string[]} previousValues + * @param {string} [cwd] - Optional working directory for the git config command * @returns {Promise} */ -async function restorePersistedExtraheader(serverUrl, previousValues) { +async function restorePersistedExtraheader(serverUrl, previousValues, cwd) { const key = `http.${normalizeServerUrl(serverUrl)}/.extraheader`; if (!previousValues || previousValues.length === 0) { core.info(`git_auth_helpers: no previous extraheader values — unsetting ${key}`); try { - await exec.exec("git", ["config", "--unset-all", key]); + if (cwd) { + await exec.exec("git", ["config", "--unset-all", key], { cwd }); + } else { + await exec.exec("git", ["config", "--unset-all", key]); + } } catch { // Nothing to restore/unset. } @@ -108,14 +117,25 @@ async function restorePersistedExtraheader(serverUrl, previousValues) { // best-effort cleanup by unsetting the key entirely, then re-throw so the caller // is aware that restoration failed. try { - await exec.exec("git", ["config", "--replace-all", key, previousValues[0]]); - for (const value of previousValues.slice(1)) { - await exec.exec("git", ["config", "--add", key, value]); + if (cwd) { + await exec.exec("git", ["config", "--replace-all", key, previousValues[0]], { cwd }); + for (const value of previousValues.slice(1)) { + await exec.exec("git", ["config", "--add", key, value], { cwd }); + } + } else { + await exec.exec("git", ["config", "--replace-all", key, previousValues[0]]); + for (const value of previousValues.slice(1)) { + await exec.exec("git", ["config", "--add", key, value]); + } } } catch (err) { core.warning(`git_auth_helpers: partial extraheader restore for ${key} — attempting cleanup: ${getErrorMessage(err)}`); try { - await exec.exec("git", ["config", "--unset-all", key]); + if (cwd) { + await exec.exec("git", ["config", "--unset-all", key], { cwd }); + } else { + await exec.exec("git", ["config", "--unset-all", key]); + } } catch { // Best-effort only; ignore secondary failure. } diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index 05afe149184..69c40f2bd20 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -11,6 +11,7 @@ const { ERR_API } = require("./error_codes.cjs"); const { loadTemporaryIdMapFromResolved, replaceTemporaryIdReferencesInPatch, TEMPORARY_ID_CANDIDATE_REFERENCE_PATTERN } = require("./temporary_id.cjs"); const { checkFileProtectionPostApply } = require("./manifest_file_helpers.cjs"); const { backfillCommitObjects } = require("./git_helpers.cjs"); +const { overridePersistedExtraheader, restorePersistedExtraheader } = require("./git_auth_helpers.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const OID_PATTERN = /^[0-9a-f]{40}$/i; @@ -209,6 +210,41 @@ function maybeReplaceTemporaryIdsInBase64Content(base64Content, temporaryIdMap, return Buffer.from(replaced, "utf8").toString("base64"); } +/** + * Probe the remote for a branch HEAD OID using ls-remote. + * Uses an explicit push remote URL and token when provided (fork-backed pushes). + * + * @param {string} branch + * @param {string} cwd + * @param {any} [gitAuthEnv] + * @param {string} [pushRemoteUrl] + * @param {string} [pushToken] + * @returns {Promise} The OID if the branch exists on the remote, otherwise undefined + */ +async function lsRemoteHeadOid(branch, cwd, gitAuthEnv, pushRemoteUrl, pushToken) { + const remote = pushRemoteUrl || "origin"; + const doLsRemote = () => exec.getExecOutput("git", ["ls-remote", remote, `refs/heads/${branch}`], { cwd, env: { ...process.env, ...(gitAuthEnv || {}) } }); + let result; + if (pushRemoteUrl && pushToken) { + const githubServerUrl = (process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, ""); + let previousExtraheaders = []; + let overrideApplied = false; + try { + previousExtraheaders = await overridePersistedExtraheader(githubServerUrl, pushToken, cwd); + overrideApplied = true; + result = await doLsRemote(); + } finally { + if (overrideApplied) { + await restorePersistedExtraheader(githubServerUrl, previousExtraheaders, cwd); + } + } + } else { + result = await doLsRemote(); + } + const resolved = result.stdout.trim().split(/\s+/)[0]; + return OID_PATTERN.test(resolved) ? resolved : undefined; +} + /** * Push the local branch to origin using git directly and return the local HEAD * SHA after the push succeeds. @@ -217,14 +253,35 @@ function maybeReplaceTemporaryIdsInBase64Content(base64Content, temporaryIdMap, * @param {string} opts.branch * @param {string} opts.cwd * @param {any} [opts.gitAuthEnv] + * @param {string} [opts.pushRemoteUrl] + * @param {string} [opts.pushToken] * @returns {Promise} */ -async function pushBranchAndResolveHead({ branch, cwd, gitAuthEnv }) { - await exec.exec("git", ["push", "origin", branch], { - cwd, - env: { ...process.env, ...(gitAuthEnv || {}) }, - }); - return resolveLocalHeadSha(cwd); +async function pushBranchAndResolveHead({ branch, cwd, gitAuthEnv, pushRemoteUrl, pushToken }) { + const pushArgs = pushRemoteUrl ? ["push", pushRemoteUrl, branch] : ["push", "origin", branch]; + const pushOnce = async () => { + await exec.exec("git", pushArgs, { + cwd, + env: { ...process.env, ...(gitAuthEnv || {}) }, + }); + return resolveLocalHeadSha(cwd); + }; + if (!pushRemoteUrl || !pushToken) { + return pushOnce(); + } + + const githubServerUrl = (process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, ""); + let previousExtraheaders = []; + let overrideApplied = false; + try { + previousExtraheaders = await overridePersistedExtraheader(githubServerUrl, pushToken, cwd); + overrideApplied = true; + return await pushOnce(); + } finally { + if (overrideApplied) { + await restorePersistedExtraheader(githubServerUrl, previousExtraheaders, cwd); + } + } } /** @@ -288,6 +345,8 @@ async function resolveLocalHeadSha(cwd) { * @param {string} opts.baseRef - Git ref of the remote head before commits were applied (used for rev-list) * @param {string} opts.cwd - Working directory of the local git checkout * @param {any} [opts.gitAuthEnv] - Environment variables for git push fallback auth + * @param {string} [opts.pushRemoteUrl] - Optional explicit git remote URL used for direct git push fallback + * @param {string} [opts.pushToken] - Optional token used when pushing to pushRemoteUrl * @param {boolean} [opts.signedCommits=true] - When false, skip GraphQL signed commits and use git push directly * @param {boolean} [opts.allowGitPushFallback=true] - When false, refuse any fallback path that would use direct git push * @param {Record} [opts.resolvedTemporaryIds] - Resolved temporary IDs map @@ -295,7 +354,7 @@ async function resolveLocalHeadSha(cwd) { * @param {Record} [opts.validationConfig] - Optional safe-output policy config applied to synthesized GraphQL fileChanges * @returns {Promise} SHA of the commit that landed on the target branch */ -async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, cwd, gitAuthEnv, signedCommits = true, allowGitPushFallback = true, resolvedTemporaryIds, currentRepo, validationConfig }) { +async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, cwd, gitAuthEnv, pushRemoteUrl, pushToken, signedCommits = true, allowGitPushFallback = true, resolvedTemporaryIds, currentRepo, validationConfig }) { const effectiveCurrentRepo = currentRepo || `${owner}/${repo}`; const temporaryIdMap = loadTemporaryIdMapFromResolved(resolvedTemporaryIds, { defaultRepo: effectiveCurrentRepo, @@ -308,7 +367,7 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c // The default parameter value converts undefined to true; this check tests only the explicit false value. if (signedCommits === false) { core.info(`pushSignedCommits: signed-commits disabled (using direct git push) for branch ${branch}`); - const headSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv }); + const headSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv, pushRemoteUrl, pushToken }); core.info(`pushSignedCommits: git push and HEAD resolution completed, HEAD=${headSha}`); return headSha; } @@ -323,7 +382,7 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c } core.info(`pushSignedCommits: empty baseRef detected (orphan branch first push), using git push directly for branch ${branch}`); try { - const headSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv }); + const headSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv, pushRemoteUrl, pushToken }); core.info(`pushSignedCommits: git push completed for orphan branch, HEAD=${headSha}`); return headSha; } catch (pushErr) { @@ -386,11 +445,7 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c let remoteHeadOid; try { - const { stdout: oidOut } = await exec.getExecOutput("git", ["ls-remote", "origin", `refs/heads/${branch}`], { cwd, env: { ...process.env, ...(gitAuthEnv || {}) } }); - const resolved = oidOut.trim().split(/\s+/)[0]; - if (OID_PATTERN.test(resolved)) { - remoteHeadOid = resolved; - } + remoteHeadOid = await lsRemoteHeadOid(branch, cwd, gitAuthEnv, pushRemoteUrl, pushToken); } catch { // Non-fatal. The existing branch-creation logic handles missing remote refs. } @@ -641,8 +696,8 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c core.info(`pushSignedCommits: using chained OID from previous mutation: ${expectedHeadOid}`); } else { // First commit: check whether the branch already exists on the remote. - const { stdout: oidOut } = await exec.getExecOutput("git", ["ls-remote", "origin", `refs/heads/${branch}`], { cwd, env: { ...process.env, ...(gitAuthEnv || {}) } }); - expectedHeadOid = oidOut.trim().split(/\s+/)[0]; + const oidResult = await lsRemoteHeadOid(branch, cwd, gitAuthEnv, pushRemoteUrl, pushToken); + expectedHeadOid = oidResult || ""; if (!expectedHeadOid) { // Branch does not exist on the remote yet. // createCommitOnBranch requires the branch to already exist – it does NOT auto-create branches. @@ -677,15 +732,11 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c // GitHub returns 422 "Reference refs/heads/ already exists". Treat that as success and continue. if (status === 422 && /reference.*already exists/i.test(message)) { core.info(`pushSignedCommits: remote branch ${branch} was created concurrently (422 Reference already exists); continuing with signed commits`); - const { stdout: refreshedOidOut } = await exec.getExecOutput("git", ["ls-remote", "origin", `refs/heads/${branch}`], { cwd, env: { ...process.env, ...(gitAuthEnv || {}) } }); - const refreshedHeadOid = refreshedOidOut.trim().split(/\s+/)[0]; - if (!refreshedHeadOid) { - throw new Error(`${ERR_API}: Could not resolve remote branch OID for ${branch} after concurrent creation; ls-remote output was ${JSON.stringify(refreshedOidOut)}`); - } - if (!OID_PATTERN.test(refreshedHeadOid)) { - throw new Error(`${ERR_API}: Invalid remote branch OID for ${branch} after concurrent creation; ls-remote output was ${JSON.stringify(refreshedOidOut)}`); + const refreshedOid = await lsRemoteHeadOid(branch, cwd, gitAuthEnv, pushRemoteUrl, pushToken); + if (!refreshedOid) { + throw new Error(`${ERR_API}: Could not resolve remote branch OID for ${branch} after concurrent creation`); } - expectedHeadOid = refreshedHeadOid; + expectedHeadOid = refreshedOid; } else { throw createRefError; } @@ -749,7 +800,7 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c throw new Error(`pushSignedCommits: signed commit push failed for branch '${branch}' and git push fallback is disabled: ${getErrorMessage(err)}`, { cause: err }); } core.warning(`pushSignedCommits: GraphQL signed push failed, falling back to git push: ${getErrorMessage(err)}`); - const fallbackSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv }); + const fallbackSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv, pushRemoteUrl, pushToken }); core.info(`pushSignedCommits: git push fallback completed, using pushed SHA ${fallbackSha}`); return fallbackSha; } diff --git a/actions/setup/js/push_signed_commits.test.cjs b/actions/setup/js/push_signed_commits.test.cjs index 00eb9920a33..ecdea7a3b89 100644 --- a/actions/setup/js/push_signed_commits.test.cjs +++ b/actions/setup/js/push_signed_commits.test.cjs @@ -2146,4 +2146,173 @@ describe("push_signed_commits integration tests", () => { expect(githubClient.graphql).not.toHaveBeenCalled(); }); }); + + // ────────────────────────────────────────────────────── + // Fork-backed push (pushRemoteUrl + pushToken) + // ────────────────────────────────────────────────────── + + describe("fork-backed push via pushRemoteUrl", () => { + let forkBareDir; + + beforeEach(() => { + forkBareDir = fs.mkdtempSync(path.join(os.tmpdir(), "push-signed-fork-")); + execGit(["init", "--bare"], { cwd: forkBareDir }); + execGit(["symbolic-ref", "HEAD", "refs/heads/main"], { cwd: forkBareDir }); + // Seed the fork with the same initial commit as origin so pushes have a common base + execGit(["push", forkBareDir, "main"], { cwd: workDir }); + }); + + afterEach(() => { + cleanupDir(forkBareDir); + }); + + it("routes direct git push to the fork remote, not origin, when signedCommits=false", async () => { + const branchName = "fork/direct-push-branch"; + execGit(["checkout", "-b", branchName], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "fork-file.txt"), "fork content\n"); + execGit(["add", "fork-file.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Add fork-file.txt"], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + + const headSha = await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: branchName, + baseRef: "origin/main", + cwd: workDir, + pushRemoteUrl: forkBareDir, + pushToken: "fake-token", + signedCommits: false, + }); + + // The returned SHA must be a valid 40-char OID + expect(headSha).toMatch(/^[0-9a-f]{40}$/i); + + // The branch must exist on the fork bare repo + const forkRefs = execGit(["ls-remote", "--heads", forkBareDir, `refs/heads/${branchName}`], { cwd: workDir }); + expect(forkRefs.stdout).toContain(branchName); + + // The branch must NOT exist on origin (upstream) + const originRefs = execGit(["ls-remote", "--heads", bareDir, `refs/heads/${branchName}`], { cwd: workDir }); + expect(originRefs.stdout).toBe(""); + + // GraphQL must not have been called (signedCommits=false) + expect(githubClient.graphql).not.toHaveBeenCalled(); + }); + + it("routes git push fallback to the fork remote when GraphQL mutation fails", async () => { + const branchName = "fork/graphql-fallback-branch"; + execGit(["checkout", "-b", branchName], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "fallback.txt"), "fallback content\n"); + execGit(["add", "fallback.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Add fallback.txt"], { cwd: workDir }); + + // Push the branch to the fork so ls-remote can probe its current tip + execGit(["push", forkBareDir, branchName], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient({ failWithError: new Error("GraphQL unavailable") }); + + const headSha = await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: branchName, + baseRef: "origin/main", + cwd: workDir, + pushRemoteUrl: forkBareDir, + pushToken: "fake-token", + }); + + expect(headSha).toMatch(/^[0-9a-f]{40}$/i); + + // Branch must exist on the fork + const forkRefs = execGit(["ls-remote", "--heads", forkBareDir, `refs/heads/${branchName}`], { cwd: workDir }); + expect(forkRefs.stdout).toContain(branchName); + + // Branch must NOT exist on origin + const originRefs = execGit(["ls-remote", "--heads", bareDir, `refs/heads/${branchName}`], { cwd: workDir }); + expect(originRefs.stdout).toBe(""); + }); + + it("routes ls-remote probe to the fork so a new fork branch is detected as absent", async () => { + const branchName = "fork/ls-remote-probe-branch"; + execGit(["checkout", "-b", branchName], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "probe.txt"), "probe content\n"); + execGit(["add", "probe.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Add probe.txt"], { cwd: workDir }); + + // The branch exists on origin but NOT on the fork bare repo. + // This means lsRemoteHeadOid should see no existing tip on the fork, + // which forces the GraphQL createRef path. + execGit(["push", "origin", branchName], { cwd: workDir }); + + const lsRemoteTargets = []; + const realExec = makeRealExec(workDir); + global.exec = { + getExecOutput: async (program, args, opts = {}) => { + if (program === "git" && args[0] === "ls-remote") { + lsRemoteTargets.push(args[1]); + } + return realExec.getExecOutput(program, args, opts); + }, + exec: realExec.exec, + }; + + const githubClient = makeMockGithubClient(); + + await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: branchName, + baseRef: "origin/main", + cwd: workDir, + pushRemoteUrl: forkBareDir, + pushToken: "fake-token", + }); + + // Every ls-remote call must target the fork bare directory, not "origin" + expect(lsRemoteTargets.length).toBeGreaterThan(0); + for (const target of lsRemoteTargets) { + expect(target).toBe(forkBareDir); + expect(target).not.toBe("origin"); + } + }); + + it("pushes a new orphan branch to the fork when baseRef is empty", async () => { + const branchName = "fork/orphan-branch"; + execGit(["checkout", "--orphan", branchName], { cwd: workDir }); + execGit(["reset", "--hard"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "orphan.txt"), "first commit\n"); + execGit(["add", "orphan.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Orphan first commit"], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + + const headSha = await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: branchName, + baseRef: "", + cwd: workDir, + pushRemoteUrl: forkBareDir, + pushToken: "fake-token", + }); + + expect(headSha).toMatch(/^[0-9a-f]{40}$/i); + + const forkRefs = execGit(["ls-remote", "--heads", forkBareDir, `refs/heads/${branchName}`], { cwd: workDir }); + expect(forkRefs.stdout).toContain(branchName); + + // Upstream must not receive the orphan branch + const originRefs = execGit(["ls-remote", "--heads", bareDir, `refs/heads/${branchName}`], { cwd: workDir }); + expect(originRefs.stdout).toBe(""); + }); + }); }); diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs index a422f84ff63..2dee8698930 100644 --- a/actions/setup/js/push_to_pull_request_branch.cjs +++ b/actions/setup/js/push_to_pull_request_branch.cjs @@ -17,6 +17,7 @@ const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { checkFileProtection, checkFileProtectionPostApply } = require("./manifest_file_helpers.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { renderTemplateFromFile, buildProtectedFileList, getPromptPath } = require("./messages_core.cjs"); +const { overridePersistedExtraheader, restorePersistedExtraheader } = require("./git_auth_helpers.cjs"); const { ensureFullHistoryForBundle, extractBundlePrerequisiteCommits, isShallowOrSparseCheckout, linearizeRangeAsCommit } = require("./git_helpers.cjs"); const { normalizeCommitSHA } = require("./commit_sha_helpers.cjs"); const { findRepoCheckout } = require("./find_repo_checkout.cjs"); @@ -102,6 +103,33 @@ function parsePositiveInteger(value) { return Number.isInteger(parsed) && parsed > 0 ? parsed : null; } +/** + * Temporarily override the persisted GitHub extraheader for remote git operations. + * + * @template T + * @param {string} token + * @param {() => Promise} callback + * @param {string} [cwd] - Optional working directory; scopes the git config override to the correct checkout + * @returns {Promise} + */ +async function withGitHubHostToken(token, callback, cwd) { + if (!token) { + return callback(); + } + const githubServerUrl = (process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, ""); + let previousExtraheaders = []; + let overrideApplied = false; + try { + previousExtraheaders = await overridePersistedExtraheader(githubServerUrl, token, cwd); + overrideApplied = true; + return await callback(); + } finally { + if (overrideApplied) { + await restorePersistedExtraheader(githubServerUrl, previousExtraheaders, cwd); + } + } +} + /** * Uses git as the source of truth for the files modified by a fetched bundle ref. * @@ -310,6 +338,8 @@ async function main(config = {}) { // Base branch from config (if set) - used only for logging at factory level // Dynamic base branch resolution happens per-message after resolving the actual target repo const configBaseBranch = config.base_branch || null; + const configuredHeadRepo = typeof config["head-repo"] === "string" ? config["head-repo"].trim() : ""; + const headGitHubToken = typeof config["head-github-token"] === "string" ? config["head-github-token"].trim() : ""; // Check if we're in staged mode (either globally or per-handler config) const isStaged = isStagedMode(config); @@ -335,6 +365,9 @@ async function main(config = {}) { core.info(`Max patch size: ${maxSizeKb} KB`); core.info(`Max count: ${maxCount || "unlimited"}`); core.info(`Default target repo: ${defaultTargetRepo}`); + if (configuredHeadRepo) { + core.info(`Configured head repo: ${configuredHeadRepo}`); + } if (allowedRepos.size > 0) { core.info(`Allowed repos: ${[...allowedRepos].join(", ")}`); } @@ -653,19 +686,66 @@ async function main(config = {}) { return { success: false, error: `Failed to determine branch name for PR ${pullNumber} in ${itemRepo}` }; } - // SECURITY: Check if this is a fork PR - we cannot push to fork branches - // The workflow token only has access to the base repository, not the fork - const { isFork, reason: forkReason } = detectForkPR(pullRequest); - if (isFork) { - core.error(`Cannot push to fork PR branch: ${forkReason}`); - core.error("The workflow token does not have permission to push to fork repositories."); - core.error("Fork PRs must be updated by the fork owner or through other mechanisms."); + let pushRepo = itemRepo; + let pushRepoParts = repoParts; + let pushGithubClient = githubClient; + const actualHeadRepo = typeof pullRequest.head?.repo?.full_name === "string" ? pullRequest.head.repo.full_name : ""; + + // SECURITY: When head.repo is null (likely a deleted fork) we cannot verify which + // repository the PR head came from. Always reject to prevent writes to an unverifiable PR. + if (pullRequest.head?.repo == null) { + const nullHeadErr = "Cannot push to PR: head repository is null (likely a deleted fork)"; + core.error(nullHeadErr); + return { success: false, error: nullHeadErr }; + } + + // SECURITY: Validate the PR head repository against the configured or default expected value + // before any fork-status branching. This prevents writes to a same-repo PR when head-repo + // names an automation fork, and prevents writes to a fork PR when head-repo is not set. + const expectedHeadRepo = configuredHeadRepo || itemRepo; + if (actualHeadRepo && actualHeadRepo.toLowerCase() !== expectedHeadRepo.toLowerCase()) { + const { isFork: actualIsFork } = detectForkPR(pullRequest); + if (actualIsFork && !configuredHeadRepo) { + core.error(`Cannot push to fork PR branch: head is '${actualHeadRepo}', not '${itemRepo}'`); + core.error("Fork PRs remain blocked unless safe-outputs.push-to-pull-request-branch.head-repo is configured."); + return { + success: false, + error: `Cannot push to fork PR: head repository '${actualHeadRepo}' does not match target '${itemRepo}'. Configure safe-outputs.push-to-pull-request-branch.head-repo and matching credentials to allow an automation-owned fork.`, + }; + } return { success: false, - error: `Cannot push to fork PR: ${forkReason}. The workflow token does not have permission to push to fork repositories.`, + error: `Cannot push to PR: head repository '${actualHeadRepo}' does not match expected '${expectedHeadRepo}'. Writes to repositories other than the configured head-repo remain blocked.`, }; } - core.info(`Fork PR check: not a fork (${forkReason})`); + + // SECURITY: Check if this is a fork PR - only explicitly configured automation-owned + // forks are eligible for updates. + const { isFork, reason: forkReason } = detectForkPR(pullRequest); + if (isFork) { + if (!configuredHeadRepo) { + core.error(`Cannot push to fork PR branch: ${forkReason}`); + core.error("Fork PRs remain blocked unless safe-outputs.push-to-pull-request-branch.head-repo is configured."); + return { + success: false, + error: `Cannot push to fork PR: ${forkReason}. Configure safe-outputs.push-to-pull-request-branch.head-repo and matching credentials to allow an automation-owned fork.`, + }; + } + const headRepoResult = resolveAndValidateRepo({ repo: configuredHeadRepo }, itemRepo, allowedRepos, "pull request head repository"); + if (!headRepoResult.success) { + return { success: false, error: headRepoResult.error }; + } + pushRepo = headRepoResult.repo; + pushRepoParts = headRepoResult.repoParts; + if (headGitHubToken && typeof global.getOctokit === "function") { + pushGithubClient = global.getOctokit(headGitHubToken); + } + core.info(`Fork PR update allowed via configured head repo: ${pushRepo}`); + } else { + core.info(`Fork PR check: not a fork (${forkReason})`); + } + const pushRemoteUrl = pushRepo.toLowerCase() === itemRepo.toLowerCase() ? "" : `${(process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, "")}/${pushRepo}.git`; + const branchRemoteName = pushRemoteUrl || "origin"; // SECURITY: Sanitize branch name to prevent shell injection (CWE-78) // Branch names from GitHub API must be normalized before use in git commands @@ -682,6 +762,7 @@ async function main(config = {}) { core.info(`Branch name sanitized: "${originalBranchName}" -> "${branchName}"`); } } + const branchRemoteRef = pushRemoteUrl ? `refs/remotes/gh-aw-head/${branchName}` : `refs/remotes/origin/${branchName}`; core.info(`Target branch: ${branchName}`); core.info(`PR title: ${prTitle}`); @@ -692,7 +773,7 @@ async function main(config = {}) { // This prevents agents from pushing directly to branches that should only receive // changes through reviewed pull requests. { - const blockReason = await checkBranchPushable(githubClient, repoParts.owner, repoParts.repo, branchName, checkBranchProtection); + const blockReason = await checkBranchPushable(pushGithubClient, pushRepoParts.owner, pushRepoParts.repo, branchName, checkBranchProtection); if (blockReason) { core.error(blockReason); return { success: false, error: blockReason }; @@ -784,11 +865,16 @@ async function main(config = {}) { // Detect missing/deleted branches early and return a clear error. // This avoids an opaque git fetch exit code when the PR branch was deleted. { - const lsRemoteResult = await exec.getExecOutput("git", ["ls-remote", "--exit-code", "--heads", "origin", branchName], { - env: { ...process.env, ...gitAuthEnv }, - ...baseGitOpts, - ignoreReturnCode: true, - }); + const lsRemoteResult = await withGitHubHostToken( + headGitHubToken, + async () => + exec.getExecOutput("git", ["ls-remote", "--exit-code", "--heads", branchRemoteName, branchName], { + env: { ...process.env, ...gitAuthEnv }, + ...baseGitOpts, + ignoreReturnCode: true, + }), + baseGitOpts.cwd + ); if (lsRemoteResult.exitCode === 2) { const missingBranchError = MISSING_BRANCH_ERROR_TEMPLATE(branchName); @@ -810,7 +896,7 @@ async function main(config = {}) { const stderr = (lsRemoteResult.stderr || "").trim(); return { success: false, - error: `Failed to verify branch ${branchName} exists on origin: ${stderr || `git ls-remote exited with code ${lsRemoteResult.exitCode}`}`, + error: `Failed to verify branch ${branchName} exists on ${pushRepo}: ${stderr || `git ls-remote exited with code ${lsRemoteResult.exitCode}`}`, }; } } @@ -820,10 +906,15 @@ async function main(config = {}) { // for the safe_outputs job; no GIT_CONFIG_* extraheader is injected (see gitAuthEnv above). try { core.info(`Fetching branch: ${branchName}`); - await exec.exec("git", ["fetch", "origin", `${branchName}:refs/remotes/origin/${branchName}`], { - env: { ...process.env, ...gitAuthEnv }, - ...baseGitOpts, - }); + await withGitHubHostToken( + headGitHubToken, + async () => + exec.exec("git", ["fetch", branchRemoteName, `${branchName}:${branchRemoteRef}`], { + env: { ...process.env, ...gitAuthEnv }, + ...baseGitOpts, + }), + baseGitOpts.cwd + ); } catch (fetchError) { const fetchErrorMessage = getErrorMessage(fetchError); if (ignoreMissingBranchFailure && looksLikeMissingRemoteBranchError(fetchErrorMessage)) { @@ -836,7 +927,7 @@ async function main(config = {}) { // Check if branch exists on origin try { - await exec.exec(`git rev-parse --verify origin/${branchName}`, [], baseGitOpts); + await exec.exec(`git rev-parse --verify ${branchRemoteRef}`, [], baseGitOpts); } catch (verifyError) { const missingBranchError = MISSING_BRANCH_ERROR_TEMPLATE(branchName); if (ignoreMissingBranchFailure) { @@ -848,8 +939,8 @@ async function main(config = {}) { // Checkout the branch from origin try { - await exec.exec(`git checkout -B ${branchName} origin/${branchName}`, [], baseGitOpts); - core.info(`Checked out existing branch from origin: ${branchName}`); + await exec.exec(`git checkout -B ${branchName} ${branchRemoteRef}`, [], baseGitOpts); + core.info(`Checked out existing branch from ${pushRepo}: ${branchName}`); } catch (checkoutError) { return { success: false, error: `Failed to checkout branch ${branchName}: ${getErrorMessage(checkoutError)}` }; } @@ -861,7 +952,7 @@ async function main(config = {}) { let newCommitCount = 0; let remoteHeadBeforePatch = ""; let pushedCommitSha = ""; - let rangeBaseRef = `origin/${branchName}`; + let rangeBaseRef = branchRemoteRef; if (hasChanges) { // Capture HEAD before applying changes to compute new-commit count later try { @@ -892,9 +983,9 @@ async function main(config = {}) { core.info(`Note: could not fetch base_commit ${recordedBaseCommit} explicitly (${getErrorMessage(fetchError)}); will verify local availability next`); } await exec.exec("git", ["cat-file", "-e", recordedBaseCommit], baseGitOpts); - const ancestryCheck = await exec.getExecOutput("git", ["merge-base", "--is-ancestor", recordedBaseCommit, `origin/${branchName}`], { ...baseGitOpts, ignoreReturnCode: true }); + const ancestryCheck = await exec.getExecOutput("git", ["merge-base", "--is-ancestor", recordedBaseCommit, branchRemoteRef], { ...baseGitOpts, ignoreReturnCode: true }); if (ancestryCheck.exitCode !== 0) { - throw new Error(`recorded base_commit ${recordedBaseCommit} is not an ancestor of origin/${branchName}; cannot safely re-anchor patch apply`); + throw new Error(`recorded base_commit ${recordedBaseCommit} is not an ancestor of ${branchRemoteRef}; cannot safely re-anchor patch apply`); } if (remoteHeadBeforePatch && remoteHeadBeforePatch !== recordedBaseCommit) { core.warning(`Remote PR branch advanced since patch generation (remote HEAD ${remoteHeadBeforePatch}, patch base ${recordedBaseCommit}); applying patch from recorded base commit`); @@ -1185,11 +1276,18 @@ async function main(config = {}) { // 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, - }); + // For fork-backed PRs, push to the head repo remote instead of origin. + const reviewPushRemote = pushRemoteUrl || "origin"; + const reviewPushOutput = await withGitHubHostToken( + pushRemoteUrl ? headGitHubToken : "", + async () => + exec.getExecOutput("git", ["push", reviewPushRemote, reviewBranchName], { + env: { ...process.env, ...gitAuthEnv }, + ...baseGitOpts, + ignoreReturnCode: true, + }), + baseGitOpts.cwd + ); if (reviewPushOutput.exitCode !== 0) { const reviewPushStderr = (reviewPushOutput.stderr || "").trim(); // GitHub rejects pushes to branches containing .github/workflows/** changes @@ -1202,11 +1300,13 @@ async function main(config = {}) { } return buildWorkflowsScopeError("Review branch", core); } - throw new Error(`git push origin ${reviewBranchName} failed (exit code ${reviewPushOutput.exitCode}): ${reviewPushStderr}`); + throw new Error(`git push ${reviewPushRemote} ${reviewBranchName} failed (exit code ${reviewPushOutput.exitCode}): ${reviewPushStderr}`); } core.info(`Pushed review branch: ${reviewBranchName}`); - // Create PR from review branch to original branch + // Create PR from review branch to original branch. + // For fork-backed PRs, use an owner-qualified head reference. + const reviewHeadRef = pushRemoteUrl ? `${pushRepoParts.owner}:${reviewBranchName}` : reviewBranchName; const detectionReasonEnv = process.env.GH_AW_DETECTION_REASON || "unknown"; const prBody = [ "> [!CAUTION]", @@ -1227,7 +1327,7 @@ async function main(config = {}) { repo: repoParts.repo, title: `[review] ${prTitle || `Changes for #${pullNumber}`}`, body: prBody, - head: reviewBranchName, + head: reviewHeadRef, base: branchName, }); @@ -1301,13 +1401,15 @@ async function main(config = {}) { // Push the applied commits to the branch using signed GraphQL commits (outside patch try/catch so push failures are not misattributed) try { const pushedSha = await pushSignedCommits({ - githubClient, - owner: repoParts.owner, - repo: repoParts.repo, + githubClient: pushGithubClient, + owner: pushRepoParts.owner, + repo: pushRepoParts.repo, branch: branchName, baseRef: rangeBaseRef, cwd: repoCwd || process.cwd(), gitAuthEnv, + pushRemoteUrl, + pushToken: headGitHubToken, signedCommits, resolvedTemporaryIds, currentRepo: itemRepo, @@ -1360,12 +1462,19 @@ async function main(config = {}) { if (preflightError) return preflightError; await exec.exec("git", ["checkout", "-b", fallbackBranchName], baseGitOpts); - // 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, - }); + // Use getExecOutput to capture stderr for 'workflows' scope diagnostics. + // For fork-backed PRs, push to the head repo remote instead of origin. + const fallbackPushRemote = pushRemoteUrl || "origin"; + const fallbackPushOutput = await withGitHubHostToken( + pushRemoteUrl ? headGitHubToken : "", + async () => + exec.getExecOutput("git", ["push", fallbackPushRemote, fallbackBranchName], { + env: { ...process.env, ...gitAuthEnv }, + ...baseGitOpts, + ignoreReturnCode: true, + }), + baseGitOpts.cwd + ); if (fallbackPushOutput.exitCode !== 0) { const fallbackPushStderr = (fallbackPushOutput.stderr || "").trim(); if (isWorkflowsScopeRejection(fallbackPushStderr)) { @@ -1375,9 +1484,12 @@ async function main(config = {}) { } return buildWorkflowsScopeError("Fallback branch", core); } - throw new Error(`git push origin ${fallbackBranchName} failed (exit code ${fallbackPushOutput.exitCode}): ${fallbackPushStderr}`); + throw new Error(`git push ${fallbackPushRemote} ${fallbackBranchName} failed (exit code ${fallbackPushOutput.exitCode}): ${fallbackPushStderr}`); } + // For fork-backed PRs, use an owner-qualified head reference. + const fallbackHeadRef = pushRemoteUrl ? `${pushRepoParts.owner}:${fallbackBranchName}` : fallbackBranchName; + const fallbackBody = [ "> [!NOTE]", "> Direct push to the original pull request branch failed because the branch diverged (non-fast-forward).", @@ -1394,7 +1506,7 @@ async function main(config = {}) { repo: repoParts.repo, title: `[fallback] ${prTitle || `Changes for #${pullNumber}`}`, body: fallbackBody, - head: fallbackBranchName, + head: fallbackHeadRef, base: branchName, }); @@ -1409,6 +1521,7 @@ async function main(config = {}) { pull_request_url: fallbackPR.html_url, branch_name: fallbackBranchName, repo: itemRepo, + head_repo: pushRepo, number: fallbackPR.number, url: fallbackPR.html_url, }; @@ -1466,8 +1579,9 @@ async function main(config = {}) { // For cross-repo scenarios, use repoParts (the target repo) not context.repo (the workflow repo) const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; const repoUrl = `${githubServer}/${repoParts.owner}/${repoParts.repo}`; - const pushUrl = `${repoUrl}/tree/${branchName}`; - const commitUrl = `${repoUrl}/commit/${commitSha}`; + const pushRepoUrl = `${githubServer}/${pushRepoParts.owner}/${pushRepoParts.repo}`; + const pushUrl = `${pushRepoUrl}/tree/${branchName}`; + const commitUrl = `${pushRepoUrl}/commit/${commitSha}`; // Update the activation comment with commit link (if a comment was created and changes were pushed) // Pass pullNumber so a new comment is created on the PR when no activation comment exists (e.g., schedule triggers) @@ -1525,8 +1639,8 @@ async function main(config = {}) { if (hasChanges) { const ciTriggerResult = await pushExtraEmptyCommit({ branchName, - repoOwner: repoParts.owner, - repoName: repoParts.repo, + repoOwner: pushRepoParts.owner, + repoName: pushRepoParts.repo, newCommitCount, }); if (ciTriggerResult.success && !ciTriggerResult.skipped) { @@ -1539,6 +1653,7 @@ async function main(config = {}) { success: true, number: pullNumber, repo: itemRepo, + head_repo: pushRepo, url: `${repoUrl}/pull/${pullNumber}`, branch_name: branchName, commit_sha: commitSha, diff --git a/actions/setup/js/push_to_pull_request_branch.test.cjs b/actions/setup/js/push_to_pull_request_branch.test.cjs index acc295f317f..1f3178f2569 100644 --- a/actions/setup/js/push_to_pull_request_branch.test.cjs +++ b/actions/setup/js/push_to_pull_request_branch.test.cjs @@ -558,6 +558,62 @@ index 0000000..abc1234 expect(mockExec.exec).not.toHaveBeenCalled(); }); + it("should allow updates to a configured automation fork head repo", async () => { + mockContext.payload.pull_request.head.repo.full_name = "fork-owner/test-repo"; + mockContext.payload.pull_request.head.repo.owner.login = "fork-owner"; + + mockGithub.rest.pulls.get.mockResolvedValue({ + data: { + head: { + ref: "feature-branch", + repo: { + full_name: "fork-owner/test-repo", + fork: true, + }, + }, + base: { + repo: { + full_name: "test-owner/test-repo", + }, + }, + title: "Fork PR", + labels: [], + }, + }); + + createPatchFile("should-allow-updates-to-a-configured-automation-fork-head-r"); + mockExec.getExecOutput + .mockResolvedValueOnce({ exitCode: 0, stdout: "preflight-sha\trefs/heads/feature-branch\n", stderr: "" }) + .mockResolvedValueOnce({ exitCode: 0, stdout: "remote-head-before\n", stderr: "" }) + .mockResolvedValueOnce({ exitCode: 0, stdout: "abc123\n", stderr: "" }) + .mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" }); + + const pushSignedCommitsModule = require("./push_signed_commits.cjs"); + const pushSignedSpy = vi.spyOn(pushSignedCommitsModule, "pushSignedCommits").mockResolvedValue("fork-head-sha"); + + try { + const module = await loadModule(); + const handler = await module.main({ + target: "triggering", + "head-repo": "fork-owner/test-repo", + allowed_repos: ["test-owner/test-repo", "fork-owner/test-repo"], + }); + const result = await handler({ branch: "should-allow-updates-to-a-configured-automation-fork-head-r" }, {}); + + expect(result.success).toBe(true); + expect(result.head_repo).toBe("fork-owner/test-repo"); + expect(result.commit_url).toContain("fork-owner/test-repo/commit/fork-head-sha"); + expect(pushSignedSpy).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "fork-owner", + repo: "test-repo", + }) + ); + } finally { + pushSignedSpy.mockRestore(); + } + }); + it("should NOT treat same-repo PR as fork even when repo has fork flag", async () => { // A repository that is itself a fork of another repo has fork=true, // but a same-repo PR within it is NOT a cross-repo fork PR (#24208) @@ -617,11 +673,47 @@ index 0000000..abc1234 const handler = await module.main({ target: "triggering" }); const result = await handler({ branch: "should-handle-deleted-head-repo-likely-a-fork-and-fail-early" }, {}); - // When head.repo is null, this is likely a deleted fork - // The handler should give a clear error about the fork + // When head.repo is null, the handler should reject immediately before any fork checks expect(result.success).toBe(false); - expect(result.error).toContain("fork"); - expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Cannot push to fork PR")); + expect(result.error).toContain("null"); + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("head repository is null")); + }); + + it("should reject when head.repo is null even with configured head-repo", async () => { + // Even when head-repo is explicitly configured, a null head.repo means we + // cannot verify which fork the PR came from — reject to prevent writes to an + // unverifiable PR. + delete mockContext.payload.pull_request.head.repo; + + mockGithub.rest.pulls.get.mockResolvedValue({ + data: { + head: { + ref: "feature-branch", + repo: null, // Deleted fork + }, + base: { + repo: { + full_name: "test-owner/test-repo", + }, + }, + title: "Deleted Fork PR with configured head-repo", + labels: [], + }, + }); + + createPatchFile("should-reject-when-head-repo-is-null-even-with-configured-h"); + + const module = await loadModule(); + const handler = await module.main({ + target: "triggering", + "head-repo": "fork-owner/test-repo", + allowed_repos: ["test-owner/test-repo", "fork-owner/test-repo"], + }); + const result = await handler({ branch: "should-reject-when-head-repo-is-null-even-with-configured-h" }, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain("null"); + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("head repository is null")); }); }); diff --git a/actions/setup/js/safe_outputs_handlers.cjs b/actions/setup/js/safe_outputs_handlers.cjs index 0e054111d02..aa0e06e7e1b 100644 --- a/actions/setup/js/safe_outputs_handlers.cjs +++ b/actions/setup/js/safe_outputs_handlers.cjs @@ -591,6 +591,8 @@ function createHandlers(server, appendSafeOutput, config = {}) { }; } const { repoParts } = repoResult; + const configuredHeadRepo = typeof prConfig["head-repo"] === "string" ? prConfig["head-repo"].trim() : ""; + entry.head_repo = configuredHeadRepo || repoResult.repo; // Determine the working directory for git operations // If repo is specified or configured, find where it's checked out @@ -1030,6 +1032,8 @@ function createHandlers(server, appendSafeOutput, config = {}) { }; } const { repoParts } = repoResult; + const configuredHeadRepo = typeof pushConfig["head-repo"] === "string" ? pushConfig["head-repo"].trim() : ""; + entry.head_repo = configuredHeadRepo || repoResult.repo; // Determine the working directory for git operations. // Look up the checkout path when the target repo is explicitly provided by the agent diff --git a/actions/setup/js/types/safe-outputs-config.d.ts b/actions/setup/js/types/safe-outputs-config.d.ts index 12945ea3d62..94ba8bb92f7 100644 --- a/actions/setup/js/types/safe-outputs-config.d.ts +++ b/actions/setup/js/types/safe-outputs-config.d.ts @@ -91,6 +91,11 @@ interface CreatePullRequestConfig extends SafeOutputConfig { assignees?: string | string[]; draft?: boolean; "if-no-changes"?: string; + "target-repo"?: string; + "head-repo"?: string; + "allowed-repos"?: string[]; + "head-github-token"?: string; + "base-branch"?: string; "allowed-branches"?: string[]; footer?: boolean; "auto-close-issue"?: boolean | string; @@ -212,6 +217,11 @@ interface PushToPullRequestBranchConfig extends SafeOutputConfig { "required-labels"?: string[]; labels?: string[]; "if-no-changes"?: string; + "target-repo"?: string; + "head-repo"?: string; + "allowed-repos"?: string[]; + "head-github-token"?: string; + "base-branch"?: string; } /** diff --git a/docs/src/content/docs/reference/safe-outputs-pull-requests.md b/docs/src/content/docs/reference/safe-outputs-pull-requests.md index 80706167446..592d45ed2f2 100644 --- a/docs/src/content/docs/reference/safe-outputs-pull-requests.md +++ b/docs/src/content/docs/reference/safe-outputs-pull-requests.md @@ -38,8 +38,9 @@ safe-outputs: max: 3 # max PRs per run (default: 1) expires: 14 # auto-close after N days (same-repo only; also accepts 2h, 7d, 2w, 1m, 1y) if-no-changes: "warn" # "warn" (default), "error", or "ignore" - target-repo: "owner/repo" # cross-repository target - allowed-repos: ["org/repo1", "org/repo2"] # additional allowed repositories + target-repo: "owner/repo" # upstream repository that receives the PR + head-repo: "automation/fork" # optional automation-owned fork that receives the branch push + allowed-repos: ["org/repo1", "org/repo2", "automation/fork"] # allowlisted upstream/head repositories base-branch: "vnext" # PR target branch (default: github.base_ref || github.ref_name) allowed-base-branches: # allow agent to override base branch at runtime (glob patterns) - main @@ -57,12 +58,18 @@ safe-outputs: - "dist/**" max-patch-files: 300 # max unique files in the patch (default: 100) max-patch-size: 2048 # max patch size in KB (default: 4096) - github-token: ${{ secrets.SOME_CUSTOM_TOKEN }} # optional custom token for permissions + github-token: ${{ secrets.UPSTREAM_PR_TOKEN }} # optional credential for upstream PR creation + head-github-token: ${{ secrets.FORK_PUSH_TOKEN }} # optional credential for fork branch writes + head-github-app: # optional GitHub App to mint the fork credential at runtime + client-id: ${{ vars.FORK_APP_CLIENT_ID }} + private-key: ${{ secrets.FORK_APP_PRIVATE_KEY }} github-token-for-extra-empty-commit: ${{ secrets.CI_TOKEN }} # optional token to push empty commit triggering CI signed-commits: true # signed commits via GraphQL API (default: true); set false to use git push directly protected-files: fallback-to-issue # push branch, create review issue if protected files modified ``` +`target-repo` names the upstream repository that owns the base branch. `head-repo`, when set, names the automation-owned fork that receives the pushed branch. When `head-repo` differs from `target-repo`, the created pull request uses an owner-qualified head reference (`fork-owner:branch`) and both repositories must be explicitly allowlisted. + See [Cross-Repository Operations](/gh-aw/reference/cross-repository/) for `target-repo`, `allowed-repos`, and authentication configuration. ### Branch targeting @@ -117,6 +124,8 @@ An older **patch transport** (`git format-patch` / `git am --3way`) is used when The `safe_outputs` job always mirrors the agent job's checkout layout. When a `checkout:` entry places a repository in a subdirectory (a `path:` is set), `safe_outputs` checks out **every** repository to the same location the agent used — the workflow repository at the workspace root plus each cross-repo checkout at its `path:` — regardless of whether `target-repo` names a specific repository or the wildcard `"*"`. This lets a specific `target-repo` (and the two-or-more cross-repo case) operate against an identical layout. When the target repository is checked out at the workspace root (no `path:`), it is checked out there in both jobs. ::: +When `head-repo` is set, the preferred model is an ephemeral upstream-based branch: the safe output job resolves the upstream base SHA from `target-repo`, creates or refreshes a temporary branch in `head-repo` from that SHA, pushes the agent's commits there, and opens the PR back to the upstream base. Supported synchronization is limited to that configured upstream/head pairing; arbitrary reuse of unrelated fork branches is not supported. + ## Pull Request Updates (`update-pull-request:`) Updates PR title or body. Both fields are enabled by default. The `operation` field controls how body updates are applied: `append` (default), `prepend`, or `replace`. @@ -299,8 +308,8 @@ See [Cross-Repository Operations](/gh-aw/reference/cross-repository/) for docume Pushes changes to a PR's branch. Validates via `required-title-prefix` and `required-labels` to ensure only approved PRs receive changes. Multiple pushes per run are supported by setting `max` higher than 1. -:::caution[Fork PRs Not Supported] -This safe output **cannot push to PRs from forks**. Fork PRs will fail early with a clear error message. This is a security restriction—the workflow does not have write access to fork repositories. +:::caution[Fork PRs Are Restricted] +This safe output can push only to same-repository pull requests or to fork-backed pull requests whose head repository exactly matches the configured `head-repo`. Arbitrary contributor forks remain unsupported and fail early with a clear error message. ::: ```yaml wrap @@ -320,8 +329,13 @@ safe-outputs: ignore-missing-branch-failure: false # treat deleted/missing branch errors as skipped instead of failed (default: false) check-branch-protection: true # set to false to skip the branch protection pre-flight check (default: true) protected-files: fallback-to-issue # create review issue if protected files modified - target-repo: "owner/repo" # cross-repository (target repo must be checked out) - allowed-repos: ["org/repo1"] # additional allowed repositories + target-repo: "owner/repo" # upstream repository containing the PR + head-repo: "automation/fork" # required for supported fork-backed follow-up pushes + allowed-repos: ["owner/repo", "automation/fork"] # allowlisted upstream/head repositories + head-github-token: ${{ secrets.FORK_PUSH_TOKEN }} # optional credential for fork branch writes + head-github-app: # optional GitHub App to mint the fork credential at runtime + client-id: ${{ vars.FORK_APP_CLIENT_ID }} + private-key: ${{ secrets.FORK_APP_PRIVATE_KEY }} ``` When `push-to-pull-request-branch` is configured, git commands (`checkout`, `branch`, `switch`, `add`, `rm`, `commit`, `merge`) are automatically enabled. @@ -344,7 +358,7 @@ By default, pushes are replayed through GitHub's signed commit API because `sign ### Cross-repo usage -`push-to-pull-request-branch` supports pushing to pull requests in a different repository via `target-repo` (and optionally `allowed-repos`). When `target-repo` is set, **the target repository must be checked out into the workflow workspace** using the `checkout:` frontmatter field with a `path:` specified. Use `target-repo: "*"` to let the agent choose the target repository at runtime (the safe_outputs job will check out all `checkout:` repositories into subdirectories automatically). +`push-to-pull-request-branch` supports pushing to pull requests in a different repository via `target-repo` (and optionally `allowed-repos`). For fork-backed pull requests, set `head-repo` to the exact repository that owns the PR head branch; follow-up pushes are permitted only when the PR head repository matches that value exactly. When `target-repo` is set, **the target repository must be checked out into the workflow workspace** using the `checkout:` frontmatter field with a `path:` specified. Use `target-repo: "*"` to let the agent choose the target repository at runtime (the safe_outputs job will check out all `checkout:` repositories into subdirectories automatically). ```yaml wrap checkout: @@ -369,7 +383,7 @@ Like `create-pull-request`, pushes with GitHub Agentic Workflows do not trigger ### Checkout token for git operations -`create-pull-request` and `push-to-pull-request-branch` run their git operations (fetch/push) against a repository that the `safe_outputs` job checks out with credentials persisted in `.git/config`. A **single** token is persisted into that checkout, resolved with this precedence: +Same-repository and single-repository cross-repo flows persist one checkout credential into `.git/config` for handler git operations. In that mode, the token is resolved with this precedence: 1. `create-pull-request.github-token` 2. `push-to-pull-request-branch.github-token` @@ -377,7 +391,9 @@ Like `create-pull-request`, pushes with GitHub Agentic Workflows do not trigger 4. `safe-outputs.github-token` 5. The default `${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}` -Because only one token can govern the shared checkout, **if you configure both `create-pull-request` and `push-to-pull-request-branch` for the same repository, give them the same token.** If they specify different `github-token` values, the higher-precedence one wins for the checkout, so the other output's git operations run with a token you did not intend. Set the token once at `safe-outputs.github-token` (or `safe-outputs.github-app`) and let both outputs inherit it, or set identical `github-token` values on each. +Because only one token can govern that shared checkout, **if you configure both `create-pull-request` and `push-to-pull-request-branch` for the same repository, give them the same token.** If they specify different `github-token` values, the higher-precedence one wins for the checkout, so the other output's git operations run with a token you did not intend. Set the token once at `safe-outputs.github-token` (or `safe-outputs.github-app`) and let both outputs inherit it, or set identical `github-token` values on each. + +Fork-backed `create-pull-request` is different: upstream pull request management can use `github-token`, while branch writes to `head-repo` can use `head-github-token` or `head-github-app`. Prefer separate least-privilege credentials: `pull-requests: write` (and, if needed, `issues: write`) on the upstream repository, and `contents: write` only on the configured automation-owned fork. When both `head-github-token` and `head-github-app` are configured, `head-github-app` takes precedence and mints an ephemeral token scoped to the head repository at runtime. Do not use the fork credential for upstream PR management, and do not allow follow-up `push-to-pull-request-branch` against contributor-owned forks. :::note This applies to the git checkout used by the handlers' `fetch`/`push`. The GitHub API calls each handler makes still honor that handler's own `github-token` precedence. diff --git a/docs/src/content/docs/specs/safe-outputs-specification.md b/docs/src/content/docs/specs/safe-outputs-specification.md index 44859990b89..b72b2c363f0 100644 --- a/docs/src/content/docs/specs/safe-outputs-specification.md +++ b/docs/src/content/docs/specs/safe-outputs-specification.md @@ -7,7 +7,7 @@ sidebar: # Safe Outputs MCP Gateway Specification -**Version**: 1.25.1 +**Version**: 1.26.0 **Status**: Working Draft **Publication Date**: 2026-07-16 **Editor**: GitHub Agentic Workflows Team @@ -2283,7 +2283,7 @@ safe-outputs: **Purpose**: Create pull requests to propose code changes. **Default Max**: 1 -**Cross-Repository Support**: No (same-repository only) +**Cross-Repository Support**: Yes **Mandatory**: Yes (required for full conformance) **MCP Tool Schema**: @@ -2323,10 +2323,17 @@ safe-outputs: | `false` (default) | n/a | Append random hex suffix to local branch name and continue | | `true` | `false` (default) | Surface `push_failed`; caller falls back (e.g. opens an issue when `fallback-as-issue: true`) | | `true` | `true` | Force-delete the existing remote ref via `DELETE /repos/{owner}/{repo}/git/refs/heads/{branch}` and let the subsequent push recreate it from the agent's local HEAD (force-push semantics). Concurrent-deletion 422 responses with "Reference does not exist" are treated as success. | +8. **Distinct Upstream and Head Repositories**: `target-repo` identifies the upstream repository that receives the pull request and owns the base branch. `head-repo`, when configured, identifies the repository that receives the pushed branch. When `head-repo` is omitted, the head repository defaults to `target-repo`. +9. **Owner-Qualified Head Reference**: When `head-repo` differs from `target-repo`, the created pull request MUST use an owner-qualified head reference identifying the head repository owner and pushed branch. Unqualified same-name branch references MUST NOT be used in fork-backed mode. +10. **Ephemeral Fork Branch Model**: When `head-repo` differs from `target-repo`, implementations SHOULD create or refresh an ephemeral branch in `head-repo` from the resolved upstream base SHA, apply the agent changes, and open the pull request back to the upstream base. Implementations MAY support explicit synchronization of that ephemeral branch with a newer upstream base, but implicit reuse of arbitrary pre-existing fork branches MUST NOT occur. +11. **Summary and Manifest Provenance**: Successful executions MUST record `head_repo` in the safe-output summary and machine-readable manifest. **Configuration Parameters**: - `max`: Operation limit (default: 1) +- `target-repo`: Upstream repository that receives the pull request and supplies the base branch +- `head-repo`: Optional repository that receives the pushed head branch. MUST either equal `target-repo` or identify a configured automation-owned fork +- `allowed-repos`: Cross-repository allowlist applied to both `target-repo` and `head-repo` - `base-branch`: Target base branch for the PR. When omitted, the handler resolves the base branch in this order: (1) the runtime checkout manifest entry for the target repository, (2) the local checkout's `origin/HEAD`, (3) the repository default branch via `GET /repos/{owner}/{repo}`. Explicit configuration is RECOMMENDED when the safe-outputs job runs without repository credentials (e.g. cross-repo private targets). - `allowed-branches`: Allowed source branch patterns for `branch` tool input - `allowed-base-branches`: Allowed base-branch override patterns for per-run `base` tool input @@ -2336,6 +2343,9 @@ safe-outputs: - `labels`: Auto-apply labels - `title-prefix`: Prepend to titles - `footer`: Footer override +- `github-token`: Optional credential for upstream pull request creation and related upstream metadata operations +- `head-github-token`: Optional credential for `head-repo` branch writes. When specified, it SHOULD be limited to `contents: write` on `head-repo` +- `head-github-app`: Optional GitHub App configuration to mint an ephemeral credential for `head-repo` branch writes at runtime. When `head-github-app` is configured, the minted token takes precedence over `head-github-token`. The app installation MUST have `contents: write` on `head-repo` - `preserve-branch-name`: When `true`, use the agent-supplied branch name verbatim without appending a random salt suffix (default: `false`) - `recreate-ref`: When `true` (and `preserve-branch-name: true`), allows the handler to force-delete an existing remote branch ref and recreate it from the agent's local HEAD on collision. When `false` (default), an existing remote branch under `preserve-branch-name: true` causes a fallback rather than overwriting the remote ref. Has no effect when `preserve-branch-name: false`. (default: `false`) @@ -2344,6 +2354,9 @@ safe-outputs: - Branch name sanitization (prevent injection) - Patch content validation - Size limits on commits +- `head-repo` MUST be either the same repository as `target-repo` or an explicitly configured automation-owned fork; arbitrary contributor forks MUST NOT be used as write targets +- Both `target-repo` and `head-repo` MUST be validated against the configured allowlist before any push or pull request API call +- When distinct upstream and head credentials are configured, implementations MUST use the least-privilege head-repository credential only for branch writes and the upstream credential only for upstream pull request management **Required Permissions**: @@ -2364,6 +2377,11 @@ safe-outputs: - `pull-requests: write` - Pull request creation - `metadata: read` - Repository metadata (automatically granted) +**Fork-Backed Pull Request Credential Roles**: + +- **Upstream credential**: `pull-requests: write` on `target-repo`; `issues: write` is additionally required when `fallback-as-issue: true` +- **Head repository credential**: `contents: write` on `head-repo` + **With `fallback-as-issue: true`** (default): - `contents: write` - Branch creation and commit operations @@ -2376,7 +2394,8 @@ safe-outputs: - Permission requirements vary based on `fallback-as-issue` configuration - When `fallback-as-issue: true` (default), requires `issues: write` for fallback issue creation if PR creation fails - When `fallback-as-issue: false`, only requires `contents: write` and `pull-requests: write` -- Cross-repository pull requests are not supported - operations are limited to same repository +- Fork-backed pull requests are supported only when `head-repo` is explicitly configured or defaults to `target-repo`; writes to arbitrary contributor forks are forbidden +- `push_to_pull_request_branch` follow-up support is limited to pull requests whose resolved head repository exactly matches the configured `head-repo` (or `target-repo` when `head-repo` is omitted) --- @@ -2670,7 +2689,7 @@ This section provides complete definitions for all remaining safe output types. **Purpose**: Create parent-child relationships between issues using task list entries. **Default Max**: 1 -**Cross-Repository Support**: No (same repository only) +**Cross-Repository Support**: Yes (restricted) **Mandatory**: No **MCP Tool Schema**: @@ -3116,7 +3135,7 @@ This section provides complete definitions for all remaining safe output types. **Purpose**: Push commits to pull request branch for automated code changes. **Default Max**: 1 -**Cross-Repository Support**: No (same repository only) +**Cross-Repository Support**: Yes (restricted) **Mandatory**: No **Required Permissions**: @@ -3134,11 +3153,21 @@ This section provides complete definitions for all remaining safe output types. - `pull-requests: write` - Pull request metadata access - `metadata: read` - Repository metadata (automatically granted) +**Fork-Backed Follow-Up Push Credential Roles**: + +- **Upstream credential**: `pull-requests: write` on `target-repo`; `issues: write` when the implementation posts push-result comments +- **Head repository credential**: `contents: write` on `head-repo` + **Configuration Parameters**: - `max`: Operation limit (default: 1) - `base-branch`: Target base branch used to compute the incremental patch (e.g. `main`, `master`). When omitted, the handler resolves the base branch in this order: (1) the runtime checkout manifest entry for the target repository, (2) the local checkout's `origin/HEAD`, (3) the repository default branch via `GET /repos/{owner}/{repo}`. Explicit configuration is REQUIRED when the safe-outputs job runs without repository credentials (e.g. cross-repo private targets) and the checkout manifest is unavailable. - `target`: Triggering PR selector (see `submit_pull_request_review` semantics) +- `target-repo`: Upstream repository containing the pull request +- `head-repo`: Optional expected head repository for fork-backed pull requests. When omitted, the expected head repository is `target-repo` +- `allowed-repos`: Cross-repository allowlist applied to both `target-repo` and `head-repo` +- `head-github-token`: Optional credential for `head-repo` branch writes +- `head-github-app`: Optional GitHub App configuration to mint an ephemeral credential for `head-repo` branch writes at runtime; takes precedence over `head-github-token` when both are specified **Notes**: @@ -3147,6 +3176,9 @@ This section provides complete definitions for all remaining safe output types. - Validates changes don't exceed size limits before pushing - Base-branch resolution MUST NOT depend on interactive credential prompts; git operations issued by the handler MUST run with `GIT_TERMINAL_PROMPT=0` and an enforced timeout so credential-less environments fail fast rather than hanging - When `safe-outputs.push-to-pull-request-branch.target` is `"*"`, requests MUST include `pull_request_number`. +- The handler MUST refuse pushes unless the resolved pull request head repository exactly matches the configured `head-repo` (or `target-repo` when `head-repo` is omitted) +- Arbitrary contributor forks MUST remain unsupported write targets even when the upstream repository itself is allowlisted +- Successful executions MUST record `head_repo` in the safe-output summary and machine-readable manifest --- @@ -5266,6 +5298,14 @@ This specification revision aligns with directly relevant `CHANGELOG.md` entries - **Earlier changelog entry**: status comments were decoupled from default AI reaction behavior; explicit `on.status-comment` configuration is required when status comments are desired. - **Earlier changelog entry**: `command` trigger was renamed to `slash_command` with deprecation compatibility. +**Version 1.26.0** (2026-07-16): + +- **Added**: First-class fork-backed pull request semantics for `create_pull_request`, including distinct `target-repo` (upstream) and `head-repo` (automation-owned fork) roles. +- **Added**: `head-github-app` configuration for both `create_pull_request` and `push_to_pull_request_branch`, allowing a GitHub App to mint an ephemeral credential scoped to the fork/head repository at runtime. When `head-github-app` is configured, it takes precedence over `head-github-token`. +- **Specified**: Owner-qualified head references, dual-repository allowlist enforcement, least-privilege upstream/head credential roles, and required summary/manifest provenance fields (`upstream_repo`, `head_repo`, `base_sha`, `pushed_head_sha`, `credential_role`). +- **Specified**: `push_to_pull_request_branch` follow-up pushes remain limited to pull requests whose head repository exactly matches the configured `head-repo`; arbitrary contributor forks remain forbidden. +- **Updated**: Publication metadata to 1.26.0. + **Version 1.25.1** (2026-07-16): - **Specified**: When `safe-outputs.github-app.repositories` is `["*"]`, implementations MUST omit the GitHub App installation-token `repositories` parameter rather than substituting `${{ github.event.repository.name }}`. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 817f2eaa305..96b9a1f003a 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -6769,6 +6769,10 @@ "type": "string", "description": "Target repository in format 'owner/repo' for cross-repository pull request creation, or '*' to let the agent choose the target repository at runtime (requires checkout: configs with path: for each possible target). Takes precedence over trial target repo settings." }, + "head-repo": { + "type": "string", + "description": "Optional fork or head repository in format 'owner/repo' where the pull request branch is pushed. When set, the pull request is still created in target-repo but uses an owner-qualified head." + }, "allowed-repos": { "description": "List of additional repositories in format 'owner/repo' that pull requests can be created in. When specified, the agent can use a 'repo' field in the output to specify which repository to create the pull request in. The target repository (current or target-repo) is always implicitly allowed. Accepts an array or a GitHub Actions expression resolving to a comma-separated list (e.g. '${{ inputs[\\'allowed-repos\\'] }}').", "oneOf": [ @@ -6790,6 +6794,14 @@ "$ref": "#/$defs/github_token", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, + "head-github-token": { + "$ref": "#/$defs/github_token", + "description": "Optional GitHub token used only for branch writes to head-repo. Use this when the upstream PR repository and the fork head repository require different credentials." + }, + "head-github-app": { + "$ref": "#/$defs/github_app", + "description": "Optional GitHub App credentials to mint an ephemeral token for head-repo branch writes at runtime. When configured, the minted token takes precedence over head-github-token. The app installation must have contents: write on head-repo." + }, "expires": { "oneOf": [ { @@ -8990,6 +9002,18 @@ "type": "string", "description": "Target repository in format 'owner/repo' for cross-repository push to pull request branch, or '*' to let the agent choose the target repository at runtime (requires checkout: configs with path: for each possible target). Takes precedence over trial target repo settings." }, + "head-repo": { + "type": "string", + "description": "Optional head repository in format 'owner/repo' that is allowed for fork-backed pull request branch updates. When set, the pull request head repository must match exactly." + }, + "head-github-token": { + "$ref": "#/$defs/github_token", + "description": "Optional GitHub token used only for branch writes to head-repo. Use this when the upstream pull request repository and the fork head repository require different credentials." + }, + "head-github-app": { + "$ref": "#/$defs/github_app", + "description": "Optional GitHub App credentials to mint an ephemeral token for head-repo branch writes at runtime. When configured, the minted token takes precedence over head-github-token. The app installation must have contents: write on head-repo." + }, "allowed-repos": { "description": "List of additional repositories in format 'owner/repo' that push to pull request branch can target. When specified, the agent can use a 'repo' field in the output to specify which repository to push to. The target repository (current or target-repo) is always implicitly allowed. Accepts an array or a GitHub Actions expression resolving to a comma-separated list (e.g. '${{ inputs[\\'allowed-repos\\'] }}').", "oneOf": [ @@ -9242,6 +9266,10 @@ "$ref": "#/$defs/github_token", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, + "head-github-token": { + "$ref": "#/$defs/github_token", + "description": "Optional GitHub token used only for branch writes to head-repo. Use this when the upstream pull request repository and the fork head repository require different credentials." + }, "staged": { "$ref": "#/$defs/templatable_boolean", "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", diff --git a/pkg/workflow/compiler_safe_outputs_config_test.go b/pkg/workflow/compiler_safe_outputs_config_test.go index 1babdf4c4e9..497da3b82f7 100644 --- a/pkg/workflow/compiler_safe_outputs_config_test.go +++ b/pkg/workflow/compiler_safe_outputs_config_test.go @@ -3379,6 +3379,157 @@ func TestPRPolicyFieldsExpressionsPassThrough(t *testing.T) { } } +func TestForkBackedPRFieldsPassThrough(t *testing.T) { + t.Parallel() + + headRepoExpr := "${{ inputs.head-repo }}" + headTokenExpr := "${{ secrets.FORK_PAT }}" + + tests := []struct { + name string + safeOutputs *SafeOutputsConfig + handlerKey string + }{ + { + name: "create-pull-request emits fork head fields", + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("1")}, + HeadRepoSlug: headRepoExpr, + HeadGitHubToken: headTokenExpr, + }, + }, + handlerKey: "create_pull_request", + }, + { + name: "push-to-pull-request-branch emits fork head fields", + safeOutputs: &SafeOutputsConfig{ + PushToPullRequestBranch: &PushToPullRequestBranchConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("1")}, + HeadRepoSlug: headRepoExpr, + HeadGitHubToken: headTokenExpr, + }, + }, + handlerKey: "push_to_pull_request_branch", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler() + workflowData := &WorkflowData{ + Name: "Test Workflow", + SafeOutputs: tt.safeOutputs, + } + + var steps []string + compiler.addHandlerManagerConfigEnvVar(&steps, workflowData) + require.NotEmpty(t, steps, "should produce config steps") + + var configJSON string + for _, step := range steps { + if strings.Contains(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG") { + parts := strings.Split(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: ") + require.Len(t, parts, 2, "should split env var line") + configJSON = strings.TrimSpace(parts[1]) + configJSON = strings.Trim(configJSON, "\"") + configJSON = strings.ReplaceAll(configJSON, "\\\"", "\"") + } + } + require.NotEmpty(t, configJSON, "should have extracted JSON") + + var config map[string]map[string]any + require.NoError(t, json.Unmarshal([]byte(configJSON), &config), "config JSON should be valid") + + handlerConfig, ok := config[tt.handlerKey] + require.True(t, ok, "should have %s config", tt.handlerKey) + assert.Equal(t, headRepoExpr, handlerConfig["head-repo"]) + assert.Equal(t, headTokenExpr, handlerConfig["head-github-token"]) + }) + } +} + +func TestForkBackedPRHeadGitHubAppFieldsPassThrough(t *testing.T) { + t.Parallel() + + headRepoExpr := "automation-owner/vscode" + headAppID := "${{ vars.HEAD_APP_ID }}" + headAppKey := "${{ secrets.HEAD_APP_PRIVATE_KEY }}" + // When head-github-app is configured, the handler config should receive the step + // expression rather than a raw token value. + expectedHeadTokenExpr := "${{ steps.safe-outputs-head-app-token.outputs.token }}" + + tests := []struct { + name string + safeOutputs *SafeOutputsConfig + handlerKey string + }{ + { + name: "create-pull-request emits app-minted head token expression", + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("1")}, + HeadRepoSlug: headRepoExpr, + HeadGitHubApp: &GitHubAppConfig{ + AppID: headAppID, + PrivateKey: headAppKey, + }, + }, + }, + handlerKey: "create_pull_request", + }, + { + name: "push-to-pull-request-branch emits app-minted head token expression", + safeOutputs: &SafeOutputsConfig{ + PushToPullRequestBranch: &PushToPullRequestBranchConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("1")}, + HeadRepoSlug: headRepoExpr, + HeadGitHubApp: &GitHubAppConfig{ + AppID: headAppID, + PrivateKey: headAppKey, + }, + }, + }, + handlerKey: "push_to_pull_request_branch", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler() + workflowData := &WorkflowData{ + Name: "Test Workflow", + SafeOutputs: tt.safeOutputs, + } + + var steps []string + compiler.addHandlerManagerConfigEnvVar(&steps, workflowData) + require.NotEmpty(t, steps, "should produce config steps") + + var configJSON string + for _, step := range steps { + if strings.Contains(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG") { + parts := strings.Split(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: ") + require.Len(t, parts, 2, "should split env var line") + configJSON = strings.TrimSpace(parts[1]) + configJSON = strings.Trim(configJSON, "\"") + configJSON = strings.ReplaceAll(configJSON, "\\\"", "\"") + } + } + require.NotEmpty(t, configJSON, "should have extracted JSON") + + var config map[string]map[string]any + require.NoError(t, json.Unmarshal([]byte(configJSON), &config), "config JSON should be valid") + + handlerConfig, ok := config[tt.handlerKey] + require.True(t, ok, "should have %s config", tt.handlerKey) + assert.Equal(t, headRepoExpr, handlerConfig["head-repo"]) + assert.Equal(t, expectedHeadTokenExpr, handlerConfig["head-github-token"], + "head-github-app should produce the safe-outputs-head-app-token step expression") + }) + } +} + func TestCreatePullRequestProtectedFilesPolicyDefault(t *testing.T) { t.Parallel() diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index 3e4d36d2370..362917c7161 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -19,6 +19,53 @@ var consolidatedSafeOutputsJobLog = logger.New("workflow:compiler_safe_outputs_j // step starts in job.Steps (6-space indent + "- name: "). const stepNameLinePrefix = " - name: " +// getSafeOutputsHeadApp returns the first non-nil HeadGitHubApp config from +// create-pull-request or push-to-pull-request-branch handlers, used to generate +// the safe-outputs-head-app-token step. +func getSafeOutputsHeadApp(safeOutputs *SafeOutputsConfig) *GitHubAppConfig { + if safeOutputs == nil { + return nil + } + if safeOutputs.CreatePullRequests != nil && safeOutputs.CreatePullRequests.HeadGitHubApp != nil { + return safeOutputs.CreatePullRequests.HeadGitHubApp + } + if safeOutputs.PushToPullRequestBranch != nil && safeOutputs.PushToPullRequestBranch.HeadGitHubApp != nil { + return safeOutputs.PushToPullRequestBranch.HeadGitHubApp + } + return nil +} + +// getSafeOutputsHeadRepoSlug returns the HeadRepoSlug associated with the configured +// head-github-app, used to scope the minted token to the correct fork repository. +func getSafeOutputsHeadRepoSlug(safeOutputs *SafeOutputsConfig) string { + if safeOutputs == nil { + return "" + } + if safeOutputs.CreatePullRequests != nil && safeOutputs.CreatePullRequests.HeadGitHubApp != nil { + return safeOutputs.CreatePullRequests.HeadRepoSlug + } + if safeOutputs.PushToPullRequestBranch != nil && safeOutputs.PushToPullRequestBranch.HeadGitHubApp != nil { + return safeOutputs.PushToPullRequestBranch.HeadRepoSlug + } + return "" +} + +// headRepoNameFromSlug extracts the repository name (without owner) from an "owner/repo" +// slug for use as the fallback repositories value in app token minting. +// Returns an empty string when the slug is absent, cannot be parsed, or contains an expression. +// The expression guard uses "${{" as a conservative prefix; standard GitHub Actions expressions +// always begin with exactly this prefix so this check is sufficient in practice. +func headRepoNameFromSlug(slug string) string { + if slug == "" { + return "" + } + parts := strings.SplitN(slug, "/", 2) + if len(parts) == 2 && !strings.Contains(parts[1], "${{") { + return parts[1] + } + return "" +} + // messagesContainPreActivationRef reports whether any message template in cfg // contains a reference to a needs.pre_activation.outputs.* expression. // When true, the safe_outputs and conclusion jobs must declare pre_activation @@ -464,7 +511,11 @@ func (c *Compiler) buildSafeOutputsJobFromParts( safeOutputStepNames := opts.safeOutputStepNames permissions := opts.permissions threatDetectionEnabled := opts.threatDetectionEnabled - // Add GitHub App token minting step at the beginning if app is configured + + // Build all preamble token minting steps to insert before checkout/safe-output steps. + var preambleTokenSteps []string + + // Add main GitHub App token minting step if configured. if data.SafeOutputs.GitHubApp != nil { // Track whether the app token minting succeeded so the conclusion job can surface // authentication errors in the failure issue. @@ -477,13 +528,35 @@ func (c *Compiler) buildSafeOutputsJobFromParts( if hasWorkflowCallTrigger(data.On) { appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo_name }}" } - appTokenSteps := c.buildGitHubAppTokenMintStepForRepository( + preambleTokenSteps = append(preambleTokenSteps, c.buildGitHubAppTokenMintStepForRepository( data.SafeOutputs.GitHubApp, permissions, appTokenFallbackRepo, inferSingleCheckoutRepositoryForGitHubAppOwner(data), - ) - // Calculate insertion index: after setup action (if present) and artifact downloads, but before checkout and safe output steps + )...) + } + + // Add head GitHub App token minting step if any PR handler has head-github-app configured. + // The minted token is passed as head-github-token in the handler config so fork branch + // operations can authenticate to the configured automation fork without requiring the + // main token to have upstream contents: write. + if headApp := getSafeOutputsHeadApp(data.SafeOutputs); headApp != nil { + headRepoSlug := getSafeOutputsHeadRepoSlug(data.SafeOutputs) + headFallbackRepo := headRepoNameFromSlug(headRepoSlug) + preambleTokenSteps = append(preambleTokenSteps, c.buildGitHubAppTokenMintStepWithMeta( + headApp, + nil, // head token is scoped to fork operations; no job-level permissions filter + headFallbackRepo, + headRepoSlug, // used to derive the owner when HeadGitHubApp.Owner is not set + "Generate GitHub App head token", + "safe-outputs-head-app-token", + )...) + } + + // Insert all preamble token steps before checkout and safe-output handler steps. + if len(preambleTokenSteps) > 0 { + // Calculate insertion index: after setup action (if present) and artifact downloads, + // but before checkout and safe output steps. insertIndex := 0 // Count setup action steps (checkout + setup if in dev mode without action-tag, or just setup) @@ -526,7 +599,7 @@ func (c *Compiler) buildSafeOutputsJobFromParts( insertIndex += len(patchDownloadSteps) } - // Note: App token step must be inserted BEFORE shared checkout steps + // Note: App token steps must be inserted BEFORE shared checkout steps // because those steps reference steps.safe-outputs-app-token.outputs.token // // The insertion index is line-oriented; if it lands in the middle of a @@ -536,15 +609,15 @@ func (c *Compiler) buildSafeOutputsJobFromParts( } if insertIndex == len(steps) { consolidatedSafeOutputsJobLog.Printf( - "WARN: app-token insertion reached end of steps slice (len=%d); step ordering may be incorrect", + "WARN: preamble-token insertion reached end of steps slice (len=%d); step ordering may be incorrect", len(steps), ) } - // Insert app token steps + // Insert all preamble token steps at the computed index. var newSteps []string newSteps = append(newSteps, steps[:insertIndex]...) - newSteps = append(newSteps, appTokenSteps...) + newSteps = append(newSteps, preambleTokenSteps...) newSteps = append(newSteps, steps[insertIndex:]...) steps = newSteps } diff --git a/pkg/workflow/compiler_safe_outputs_job_test.go b/pkg/workflow/compiler_safe_outputs_job_test.go index 62902461135..be626645f6b 100644 --- a/pkg/workflow/compiler_safe_outputs_job_test.go +++ b/pkg/workflow/compiler_safe_outputs_job_test.go @@ -1229,6 +1229,95 @@ func TestGitHubAppTokenStepWithOTLPHeadersAndAttributes(t *testing.T) { "app token step must not split setup-agent-output-env into a nested echo under with:") } +// TestHeadGitHubAppTokenStepGenerated verifies that when head-github-app is configured on +// create-pull-request or push-to-pull-request-branch, the compiler generates a separate +// safe-outputs-head-app-token step that comes before the main safe-outputs handler step. +func TestHeadGitHubAppTokenStepGenerated(t *testing.T) { + tests := []struct { + name string + safeOutputs *SafeOutputsConfig + }{ + { + name: "create-pull-request with head-github-app", + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("5")}, + HeadRepoSlug: "fork-owner/test-repo", + HeadGitHubApp: &GitHubAppConfig{ + AppID: "${{ vars.HEAD_APP_ID }}", + PrivateKey: "${{ secrets.HEAD_APP_PRIVATE_KEY }}", + }, + }, + }, + }, + { + name: "push-to-pull-request-branch with head-github-app", + safeOutputs: &SafeOutputsConfig{ + PushToPullRequestBranch: &PushToPullRequestBranchConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("5")}, + HeadRepoSlug: "fork-owner/test-repo", + HeadGitHubApp: &GitHubAppConfig{ + AppID: "${{ vars.HEAD_APP_ID }}", + PrivateKey: "${{ secrets.HEAD_APP_PRIVATE_KEY }}", + }, + }, + }, + }, + { + name: "create-pull-request with both main and head github-app", + safeOutputs: &SafeOutputsConfig{ + GitHubApp: &GitHubAppConfig{ + AppID: "${{ vars.MAIN_APP_ID }}", + PrivateKey: "${{ secrets.MAIN_APP_PRIVATE_KEY }}", + }, + CreatePullRequests: &CreatePullRequestsConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("5")}, + HeadRepoSlug: "fork-owner/test-repo", + HeadGitHubApp: &GitHubAppConfig{ + AppID: "${{ vars.HEAD_APP_ID }}", + PrivateKey: "${{ secrets.HEAD_APP_PRIVATE_KEY }}", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler() + compiler.jobManager = NewJobManager() + + workflowData := &WorkflowData{ + Name: "Test Workflow", + SafeOutputs: tt.safeOutputs, + } + + job, _, err := compiler.buildConsolidatedSafeOutputsJob(workflowData, string(constants.AgentJobName), "test.md") + require.NoError(t, err, "Should build job without error") + require.NotNil(t, job, "Job should not be nil") + + stepsContent := strings.Join(job.Steps, "") + + // The head app token step must be present with the correct ID. + require.Contains(t, stepsContent, "id: safe-outputs-head-app-token", + "head app token step should be emitted with correct step ID") + + // The head app token step should reference the configured app credentials. + require.Contains(t, stepsContent, "${{ vars.HEAD_APP_ID }}", + "head app token step should contain head app-id expression") + require.Contains(t, stepsContent, "${{ secrets.HEAD_APP_PRIVATE_KEY }}", + "head app token step should contain head private-key expression") + + // The head app token step must come before the safe-outputs handler step. + headTokenIdx := strings.Index(stepsContent, "id: safe-outputs-head-app-token") + handlerStepIdx := strings.LastIndex(stepsContent, "Process Safe Outputs") + require.Greater(t, handlerStepIdx, -1, "handler step must be present") + assert.Less(t, headTokenIdx, handlerStepIdx, + "head app token step must be inserted before the safe outputs handler step") + }) + } +} + // a safe-output job compiled for a workflow_call trigger uses // needs.activation.outputs.target_repo_name (repo name only, no owner prefix) as the repositories // fallback for the GitHub App token mint step, instead of the full target_repo slug. diff --git a/pkg/workflow/create_pull_request.go b/pkg/workflow/create_pull_request.go index c55763c9d67..b4c0171914c 100644 --- a/pkg/workflow/create_pull_request.go +++ b/pkg/workflow/create_pull_request.go @@ -32,42 +32,45 @@ func isCloseOlderPullRequestsEnabled(config *CreatePullRequestsConfig) bool { // CreatePullRequestsConfig holds configuration for creating GitHub pull requests from agent output type CreatePullRequestsConfig struct { BaseSafeOutputConfig `yaml:",inline"` - BranchPrefix string `yaml:"branch-prefix,omitempty"` // Optional prefix for the pull request branch name (e.g. "signed/"). Applied before the agent-specified or auto-generated branch name. - TitlePrefix string `yaml:"title-prefix,omitempty"` - RequireTemporaryID bool `yaml:"require-temporary-id,omitempty"` // When true, create_pull_request tool calls must include temporary_id. - Labels []string `yaml:"labels,omitempty"` - AllowedLabels []string `yaml:"allowed-labels,omitempty"` // Optional list of allowed labels. If omitted, any labels are allowed (including creating new ones). - Reviewers []string `yaml:"reviewers,omitempty"` // List of users/bots to assign as reviewers to the pull request. Accepts a static list or a single GitHub Actions expression. - TeamReviewers []string `yaml:"team-reviewers,omitempty"` // List of team slugs to assign as team reviewers to the pull request. Accepts a static list or a single GitHub Actions expression. - Assignees []string `yaml:"assignees,omitempty"` // List of users to assign to the created pull request and any fallback issue. Accepts a static list or a single GitHub Actions expression. - FallbackLabels []string `yaml:"fallback-labels,omitempty"` // List of labels to apply to fallback issues created when PR creation cannot proceed. If omitted, fallback issues reuse PR labels. - Draft *string `yaml:"draft,omitempty"` // Pointer to distinguish between unset (nil), literal bool, and expression values - IfNoChanges string `yaml:"if-no-changes,omitempty"` // Behavior when no changes to push: "warn" (default), "error", or "ignore" - AllowEmpty *string `yaml:"allow-empty,omitempty"` // Allow creating PR without patch file or with empty patch (useful for preparing feature branches) - TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository pull requests - AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that pull requests can be created in (additionally to the target-repo) - AllowedBaseBranches []string `yaml:"allowed-base-branches,omitempty"` // List of allowed base branch globs (e.g. "release/*"). Enables agent-provided `base` override when configured. - AllowedBranches []string `yaml:"allowed-branches,omitempty"` // List of allowed source branch globs (e.g. "feature/*"). Branch in create_pull_request payload must match when configured. - MaxPatchSize int `yaml:"max-patch-size,omitempty"` // Maximum allowed patch size in KB for create-pull-request only. Overrides safe-outputs.max-patch-size when set. - MaxPatchFiles int `yaml:"max-patch-files,omitempty"` // Maximum allowed unique files in create-pull-request patch only. Overrides safe-outputs.max-patch-files when set. - Expires int `yaml:"expires,omitempty"` // Hours until the pull request expires and should be automatically closed (only for same-repo PRs) - AutoMerge *string `yaml:"auto-merge,omitempty"` // Enable auto-merge for the pull request when all required checks pass - BaseBranch string `yaml:"base-branch,omitempty"` // Base branch for the pull request (defaults to github.ref_name if not specified) - Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. - FallbackAsIssue *bool `yaml:"fallback-as-issue,omitempty"` // When true (default), creates an issue if PR creation fails. When false, no fallback occurs and issues: write permission is not requested. - AutoCloseIssue *string `yaml:"auto-close-issue,omitempty"` // Auto-add "Fixes #N" closing keyword when triggered from an issue (default: true). Set to false to prevent auto-closing the triggering issue on PR merge. Accepts a boolean or a GitHub Actions expression. - GithubTokenForExtraEmptyCommit string `yaml:"github-token-for-extra-empty-commit,omitempty"` // Token used to push an empty commit to trigger CI events. Use a PAT or "app" for GitHub App auth. - ManifestFilesPolicy *string `yaml:"protected-files,omitempty"` // Controls protected-file protection: "request_review" (default) creates a PR and submits a REQUEST_CHANGES review, "blocked" hard-blocks, "allowed" permits all changes, and "fallback-to-issue" creates a review issue instead of a PR. - ProtectedFilesExclude []string `yaml:"-"` // Files/prefixes to exclude from the default protected list (from object-form protected-files.exclude). Not sourced from YAML directly; populated during pre-processing. - AllowedFiles []string `yaml:"allowed-files,omitempty"` // Strict allowlist of glob patterns for files eligible for create. Checked independently of protected-files; both checks must pass. - ExcludedFiles []string `yaml:"excluded-files,omitempty"` // List of glob patterns for files to exclude from the patch using git :(exclude) pathspecs. Matching files are stripped by git at generation time and will not appear in the commit or be subject to allowed-files or protected-files checks. - PreserveBranchName bool `yaml:"preserve-branch-name,omitempty"` // When true, skips the random salt suffix on agent-specified branch names. Invalid characters are still replaced for security; casing is always preserved. Useful when CI enforces branch naming conventions (e.g. Jira keys in uppercase). - RecreateRef bool `yaml:"recreate-ref,omitempty"` // When true (and preserve-branch-name is true), allows the handler to force-delete an existing remote branch ref and recreate it from the agent's local HEAD. When false (default), an existing remote branch causes a fallback to issue (or push_failed). Useful for long-lived reusable branches whose previous PR was merged. - PatchFormat string `yaml:"patch-format,omitempty"` // Transport format for packaging changes: "bundle" (default, uses git bundle and preserves merge topology/per-commit metadata) or "am" (uses git format-patch). - SignedCommits *bool `yaml:"signed-commits,omitempty"` // When false, skips GitHub GraphQL signed commits and pushes the local git history directly. Default is true. - AllowWorkflows bool `yaml:"allow-workflows,omitempty"` // When true, adds workflows: write to the GitHub App token. Requires safe-outputs.github-app to be configured. - CloseOlderPullRequests *string `yaml:"close-older-pull-requests,omitempty"` // When true, close older open pull requests with the same workflow-id marker when a new one is created. Capped at 10 closures per run. - CloseOlderKey string `yaml:"close-older-key,omitempty"` // Optional explicit deduplication key for close-older matching. When set, uses gh-aw-close-key marker instead of workflow-id markers. + BranchPrefix string `yaml:"branch-prefix,omitempty"` // Optional prefix for the pull request branch name (e.g. "signed/"). Applied before the agent-specified or auto-generated branch name. + TitlePrefix string `yaml:"title-prefix,omitempty"` + RequireTemporaryID bool `yaml:"require-temporary-id,omitempty"` // When true, create_pull_request tool calls must include temporary_id. + Labels []string `yaml:"labels,omitempty"` + AllowedLabels []string `yaml:"allowed-labels,omitempty"` // Optional list of allowed labels. If omitted, any labels are allowed (including creating new ones). + Reviewers []string `yaml:"reviewers,omitempty"` // List of users/bots to assign as reviewers to the pull request. Accepts a static list or a single GitHub Actions expression. + TeamReviewers []string `yaml:"team-reviewers,omitempty"` // List of team slugs to assign as team reviewers to the pull request. Accepts a static list or a single GitHub Actions expression. + Assignees []string `yaml:"assignees,omitempty"` // List of users to assign to the created pull request and any fallback issue. Accepts a static list or a single GitHub Actions expression. + FallbackLabels []string `yaml:"fallback-labels,omitempty"` // List of labels to apply to fallback issues created when PR creation cannot proceed. If omitted, fallback issues reuse PR labels. + Draft *string `yaml:"draft,omitempty"` // Pointer to distinguish between unset (nil), literal bool, and expression values + IfNoChanges string `yaml:"if-no-changes,omitempty"` // Behavior when no changes to push: "warn" (default), "error", or "ignore" + AllowEmpty *string `yaml:"allow-empty,omitempty"` // Allow creating PR without patch file or with empty patch (useful for preparing feature branches) + TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository pull requests + HeadRepoSlug string `yaml:"head-repo,omitempty"` // Head repository in format "owner/repo" for fork-backed pull requests; defaults to target-repo when unset + HeadGitHubToken string `yaml:"head-github-token,omitempty"` // GitHub token used for branch writes to the head repository when it differs from the target repo + HeadGitHubApp *GitHubAppConfig `yaml:"-"` // GitHub App used to mint the head token for fork branch writes; parsed manually to support app-id alias + AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that pull requests can be created in (additionally to the target-repo) + AllowedBaseBranches []string `yaml:"allowed-base-branches,omitempty"` // List of allowed base branch globs (e.g. "release/*"). Enables agent-provided `base` override when configured. + AllowedBranches []string `yaml:"allowed-branches,omitempty"` // List of allowed source branch globs (e.g. "feature/*"). Branch in create_pull_request payload must match when configured. + MaxPatchSize int `yaml:"max-patch-size,omitempty"` // Maximum allowed patch size in KB for create-pull-request only. Overrides safe-outputs.max-patch-size when set. + MaxPatchFiles int `yaml:"max-patch-files,omitempty"` // Maximum allowed unique files in create-pull-request patch only. Overrides safe-outputs.max-patch-files when set. + Expires int `yaml:"expires,omitempty"` // Hours until the pull request expires and should be automatically closed (only for same-repo PRs) + AutoMerge *string `yaml:"auto-merge,omitempty"` // Enable auto-merge for the pull request when all required checks pass + BaseBranch string `yaml:"base-branch,omitempty"` // Base branch for the pull request (defaults to github.ref_name if not specified) + Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. + FallbackAsIssue *bool `yaml:"fallback-as-issue,omitempty"` // When true (default), creates an issue if PR creation fails. When false, no fallback occurs and issues: write permission is not requested. + AutoCloseIssue *string `yaml:"auto-close-issue,omitempty"` // Auto-add "Fixes #N" closing keyword when triggered from an issue (default: true). Set to false to prevent auto-closing the triggering issue on PR merge. Accepts a boolean or a GitHub Actions expression. + GithubTokenForExtraEmptyCommit string `yaml:"github-token-for-extra-empty-commit,omitempty"` // Token used to push an empty commit to trigger CI events. Use a PAT or "app" for GitHub App auth. + ManifestFilesPolicy *string `yaml:"protected-files,omitempty"` // Controls protected-file protection: "request_review" (default) creates a PR and submits a REQUEST_CHANGES review, "blocked" hard-blocks, "allowed" permits all changes, and "fallback-to-issue" creates a review issue instead of a PR. + ProtectedFilesExclude []string `yaml:"-"` // Files/prefixes to exclude from the default protected list (from object-form protected-files.exclude). Not sourced from YAML directly; populated during pre-processing. + AllowedFiles []string `yaml:"allowed-files,omitempty"` // Strict allowlist of glob patterns for files eligible for create. Checked independently of protected-files; both checks must pass. + ExcludedFiles []string `yaml:"excluded-files,omitempty"` // List of glob patterns for files to exclude from the patch using git :(exclude) pathspecs. Matching files are stripped by git at generation time and will not appear in the commit or be subject to allowed-files or protected-files checks. + PreserveBranchName bool `yaml:"preserve-branch-name,omitempty"` // When true, skips the random salt suffix on agent-specified branch names. Invalid characters are still replaced for security; casing is always preserved. Useful when CI enforces branch naming conventions (e.g. Jira keys in uppercase). + RecreateRef bool `yaml:"recreate-ref,omitempty"` // When true (and preserve-branch-name is true), allows the handler to force-delete an existing remote branch ref and recreate it from the agent's local HEAD. When false (default), an existing remote branch causes a fallback to issue (or push_failed). Useful for long-lived reusable branches whose previous PR was merged. + PatchFormat string `yaml:"patch-format,omitempty"` // Transport format for packaging changes: "bundle" (default, uses git bundle and preserves merge topology/per-commit metadata) or "am" (uses git format-patch). + SignedCommits *bool `yaml:"signed-commits,omitempty"` // When false, skips GitHub GraphQL signed commits and pushes the local git history directly. Default is true. + AllowWorkflows bool `yaml:"allow-workflows,omitempty"` // When true, adds workflows: write to the GitHub App token. Requires safe-outputs.github-app to be configured. + CloseOlderPullRequests *string `yaml:"close-older-pull-requests,omitempty"` // When true, close older open pull requests with the same workflow-id marker when a new one is created. Capped at 10 closures per run. + CloseOlderKey string `yaml:"close-older-key,omitempty"` // Optional explicit deduplication key for close-older matching. When set, uses gh-aw-close-key marker instead of workflow-id markers. } // parseCreatePullRequestsConfig handles only create-pull-request (singular) configuration @@ -120,7 +123,7 @@ func (c *Compiler) parseCreatePullRequestsConfig(outputMap map[string]any) *Crea return true }, - func(_ map[string]any, config *CreatePullRequestsConfig, expiresDisabled bool) { + func(configData map[string]any, config *CreatePullRequestsConfig, expiresDisabled bool) { if expiresDisabled { createPRLog.Print("Pull request expiration disabled") } @@ -140,6 +143,16 @@ func (c *Compiler) parseCreatePullRequestsConfig(outputMap map[string]any) *Crea } else { createPRLog.Printf("Pull request max count configured: %s", *config.Max) } + + // Parse head-github-app manually so that the app-id alias is honoured + // (YAML unmarshal would silently ignore app-id since GitHubAppConfig only + // declares the canonical client-id tag). + if headAppData, exists := configData["head-github-app"]; exists { + if headAppMap, ok := headAppData.(map[string]any); ok { + createPRLog.Print("Parsed head-github-app from config") + config.HeadGitHubApp = parseAppConfig(headAppMap) + } + } }, ) if config == nil { diff --git a/pkg/workflow/github_token.go b/pkg/workflow/github_token.go index e9f5ea15f64..72f5f60a160 100644 --- a/pkg/workflow/github_token.go +++ b/pkg/workflow/github_token.go @@ -142,7 +142,10 @@ func resolvePRCheckoutToken(safeOutputs *SafeOutputsConfig, checkoutMgr *Checkou pushToPRBranchToken = safeOutputs.PushToPullRequestBranch.GitHubToken } - // Per-config PAT tokens take highest precedence (overrides GitHub App) + // Per-config PAT tokens take highest precedence (overrides GitHub App). + // head-github-token is intentionally excluded: it is a fork-write credential + // scoped to head-repo only and must not be used for the shared checkout or + // upstream API calls. perConfigToken := createPRToken if perConfigToken == "" { perConfigToken = pushToPRBranchToken @@ -182,11 +185,17 @@ func resolvePRCheckoutTargetRepo(safeOutputs *SafeOutputsConfig) string { return "" } if safeOutputs.CreatePullRequests != nil { + if repo := strings.TrimSpace(safeOutputs.CreatePullRequests.HeadRepoSlug); repo != "" && repo != "*" { + return repo + } if repo := strings.TrimSpace(safeOutputs.CreatePullRequests.TargetRepoSlug); repo != "" && repo != "*" { return repo } } if safeOutputs.PushToPullRequestBranch != nil { + if repo := strings.TrimSpace(safeOutputs.PushToPullRequestBranch.HeadRepoSlug); repo != "" && repo != "*" { + return repo + } if repo := strings.TrimSpace(safeOutputs.PushToPullRequestBranch.TargetRepoSlug); repo != "" && repo != "*" { return repo } @@ -217,9 +226,15 @@ func resolveStaticCheckoutToken(safeOutputs *SafeOutputsConfig, checkoutMgr *Che return getEffectiveSafeOutputGitHubToken("") } + if safeOutputs.CreatePullRequests != nil && safeOutputs.CreatePullRequests.HeadGitHubToken != "" { + return getEffectiveSafeOutputGitHubToken(safeOutputs.CreatePullRequests.HeadGitHubToken) + } if safeOutputs.CreatePullRequests != nil && safeOutputs.CreatePullRequests.GitHubToken != "" { return getEffectiveSafeOutputGitHubToken(safeOutputs.CreatePullRequests.GitHubToken) } + if safeOutputs.PushToPullRequestBranch != nil && safeOutputs.PushToPullRequestBranch.HeadGitHubToken != "" { + return getEffectiveSafeOutputGitHubToken(safeOutputs.PushToPullRequestBranch.HeadGitHubToken) + } if safeOutputs.PushToPullRequestBranch != nil && safeOutputs.PushToPullRequestBranch.GitHubToken != "" { return getEffectiveSafeOutputGitHubToken(safeOutputs.PushToPullRequestBranch.GitHubToken) } diff --git a/pkg/workflow/push_to_pull_request_branch.go b/pkg/workflow/push_to_pull_request_branch.go index 1e1da1997d8..78bfb96b5aa 100644 --- a/pkg/workflow/push_to_pull_request_branch.go +++ b/pkg/workflow/push_to_pull_request_branch.go @@ -13,27 +13,30 @@ var pushToPullRequestBranchLog = logger.New("workflow:push_to_pull_request_branc // PushToPullRequestBranchConfig holds configuration for pushing changes to a specific branch from agent output type PushToPullRequestBranchConfig struct { BaseSafeOutputConfig `yaml:",inline"` - Target string `yaml:"target,omitempty"` // Target for push-to-pull-request-branch: like add-comment but for pull requests - TitlePrefix string `yaml:"title-prefix,omitempty"` // Required title prefix for pull request validation - RequiredLabels []string `yaml:"required-labels,omitempty"` // Required labels for pull request validation - Labels []string `yaml:"labels,omitempty"` // Deprecated alias for required-labels - IfNoChanges string `yaml:"if-no-changes,omitempty"` // Behavior when no changes to push: "warn", "error", or "ignore" (default: "warn") - IgnoreMissingBranchFailure bool `yaml:"ignore-missing-branch-failure,omitempty"` // When true, missing/deleted target branches are treated as skipped instead of hard failures. - CommitTitleSuffix string `yaml:"commit-title-suffix,omitempty"` // Optional suffix to append to generated commit titles - GithubTokenForExtraEmptyCommit string `yaml:"github-token-for-extra-empty-commit,omitempty"` // Token used to push an empty commit to trigger CI events. Use a PAT or "app" for GitHub App auth. - TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository push to pull request branch - BaseBranch string `yaml:"base-branch,omitempty"` // Base branch of the target repository for incremental patch computation. When unset, the runtime resolves it from the local checkout or the repo's default branch. - AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories in format "owner/repo" that push to pull request branch can target - ManifestFilesPolicy *string `yaml:"protected-files,omitempty"` // Controls protected-file protection: "blocked" (default) hard-blocks, "allowed" permits all changes, "fallback-to-issue" creates a review issue instead of pushing. - ProtectedFilesExclude []string `yaml:"-"` // Files/prefixes to exclude from the default protected list (from object-form protected-files.exclude). Not sourced from YAML directly; populated during parsing. - AllowedFiles []string `yaml:"allowed-files,omitempty"` // Strict allowlist of glob patterns for files eligible for push. Checked independently of protected-files; both checks must pass. - ExcludedFiles []string `yaml:"excluded-files,omitempty"` // List of glob patterns for files to exclude from the patch using git :(exclude) pathspecs. Matching files are stripped by git at generation time and will not appear in the commit or be subject to allowed-files or protected-files checks. - MaxPatchSize int `yaml:"max-patch-size,omitempty"` // Maximum allowed patch size in KB for push-to-pull-request-branch only. Overrides safe-outputs.max-patch-size when set. - PatchFormat string `yaml:"patch-format,omitempty"` // Transport format for packaging changes: "bundle" (default, uses git bundle and preserves merge topology/per-commit metadata) or "am" (uses git format-patch). - FallbackAsPullRequest *bool `yaml:"fallback-as-pull-request,omitempty"` // When true (default), creates a fallback pull request if direct push fails due to diverged/non-fast-forward branch. When false, fallback is disabled and pull-requests: write is not requested. - SignedCommits *bool `yaml:"signed-commits,omitempty"` // When false, skips GitHub GraphQL signed commits and pushes the local git history directly. Default is true. - AllowWorkflows bool `yaml:"allow-workflows,omitempty"` // When true, adds workflows: write to the GitHub App token. Requires safe-outputs.github-app to be configured. - CheckBranchProtection *bool `yaml:"check-branch-protection,omitempty"` // When false, skips the branch protection API pre-flight check. Default is true (check enabled). Set to false to avoid needing administration: read permission. + Target string `yaml:"target,omitempty"` // Target for push-to-pull-request-branch: like add-comment but for pull requests + TitlePrefix string `yaml:"title-prefix,omitempty"` // Required title prefix for pull request validation + RequiredLabels []string `yaml:"required-labels,omitempty"` // Required labels for pull request validation + Labels []string `yaml:"labels,omitempty"` // Deprecated alias for required-labels + IfNoChanges string `yaml:"if-no-changes,omitempty"` // Behavior when no changes to push: "warn", "error", or "ignore" (default: "warn") + IgnoreMissingBranchFailure bool `yaml:"ignore-missing-branch-failure,omitempty"` // When true, missing/deleted target branches are treated as skipped instead of hard failures. + CommitTitleSuffix string `yaml:"commit-title-suffix,omitempty"` // Optional suffix to append to generated commit titles + GithubTokenForExtraEmptyCommit string `yaml:"github-token-for-extra-empty-commit,omitempty"` // Token used to push an empty commit to trigger CI events. Use a PAT or "app" for GitHub App auth. + TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository push to pull request branch + HeadRepoSlug string `yaml:"head-repo,omitempty"` // Head repository in format "owner/repo" for allowed fork-backed pull request updates + HeadGitHubToken string `yaml:"head-github-token,omitempty"` // GitHub token used for branch writes to the head repository when it differs from the target repo + HeadGitHubApp *GitHubAppConfig `yaml:"-"` // GitHub App used to mint the head token for fork branch writes; parsed manually to support app-id alias + BaseBranch string `yaml:"base-branch,omitempty"` // Base branch of the target repository for incremental patch computation. When unset, the runtime resolves it from the local checkout or the repo's default branch. + AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories in format "owner/repo" that push to pull request branch can target + ManifestFilesPolicy *string `yaml:"protected-files,omitempty"` // Controls protected-file protection: "blocked" (default) hard-blocks, "allowed" permits all changes, "fallback-to-issue" creates a review issue instead of pushing. + ProtectedFilesExclude []string `yaml:"-"` // Files/prefixes to exclude from the default protected list (from object-form protected-files.exclude). Not sourced from YAML directly; populated during parsing. + AllowedFiles []string `yaml:"allowed-files,omitempty"` // Strict allowlist of glob patterns for files eligible for push. Checked independently of protected-files; both checks must pass. + ExcludedFiles []string `yaml:"excluded-files,omitempty"` // List of glob patterns for files to exclude from the patch using git :(exclude) pathspecs. Matching files are stripped by git at generation time and will not appear in the commit or be subject to allowed-files or protected-files checks. + MaxPatchSize int `yaml:"max-patch-size,omitempty"` // Maximum allowed patch size in KB for push-to-pull-request-branch only. Overrides safe-outputs.max-patch-size when set. + PatchFormat string `yaml:"patch-format,omitempty"` // Transport format for packaging changes: "bundle" (default, uses git bundle and preserves merge topology/per-commit metadata) or "am" (uses git format-patch). + FallbackAsPullRequest *bool `yaml:"fallback-as-pull-request,omitempty"` // When true (default), creates a fallback pull request if direct push fails due to diverged/non-fast-forward branch. When false, fallback is disabled and pull-requests: write is not requested. + SignedCommits *bool `yaml:"signed-commits,omitempty"` // When false, skips GitHub GraphQL signed commits and pushes the local git history directly. Default is true. + AllowWorkflows bool `yaml:"allow-workflows,omitempty"` // When true, adds workflows: write to the GitHub App token. Requires safe-outputs.github-app to be configured. + CheckBranchProtection *bool `yaml:"check-branch-protection,omitempty"` // When false, skips the branch protection API pre-flight check. Default is true (check enabled). Set to false to avoid needing administration: read permission. } // buildCheckoutRepository generates a checkout step with optional target repository and custom token @@ -157,6 +160,18 @@ func (c *Compiler) parsePushToPullRequestBranchConfig(outputMap map[string]any) // Parse target-repo for cross-repository push pushToBranchConfig.TargetRepoSlug = extractStringFromMap(configMap, "target-repo", pushToPullRequestBranchLog) + pushToBranchConfig.HeadRepoSlug = extractStringFromMap(configMap, "head-repo", pushToPullRequestBranchLog) + pushToBranchConfig.HeadGitHubToken = extractStringFromMap(configMap, "head-github-token", pushToPullRequestBranchLog) + + // Parse head-github-app manually so that the app-id alias is honoured + // (YAML unmarshal would silently ignore app-id since GitHubAppConfig only + // declares the canonical client-id tag). + if headAppData, exists := configMap["head-github-app"]; exists { + if headAppMap, ok := headAppData.(map[string]any); ok { + pushToPullRequestBranchLog.Print("Parsed head-github-app from config") + pushToBranchConfig.HeadGitHubApp = parseAppConfig(headAppMap) + } + } // Parse base-branch for explicit override of the target repo's base branch. // When unset, the safe-outputs MCP server resolves it at runtime from the local diff --git a/pkg/workflow/safe_outputs_handler_manager_token_test.go b/pkg/workflow/safe_outputs_handler_manager_token_test.go index c001ab7b4ab..e6a0bc82fe7 100644 --- a/pkg/workflow/safe_outputs_handler_manager_token_test.go +++ b/pkg/workflow/safe_outputs_handler_manager_token_test.go @@ -94,6 +94,24 @@ func TestHandlerManagerGitHubTokenEnvVarForCrossRepo(t *testing.T) { expectedGitHubTokenLine: "GITHUB_TOKEN: ${{ secrets.PUSH_PAT }}", shouldHaveGitHubToken: true, }, + { + name: "create-pull-request head-github-token does not override checkout token", + frontmatter: map[string]any{ + "name": "Test Workflow", + "safe-outputs": map[string]any{ + "github-token": "${{ secrets.SAFE_OUTPUTS_TOKEN }}", + "create-pull-request": map[string]any{ + "head-github-token": "${{ secrets.FORK_PAT }}", + "head-repo": "fork-owner/test-repo", + "max": 2, + }, + }, + }, + // head-github-token is a fork-write credential and must not govern the checkout. + // The safe-outputs-level github-token should be used instead. + expectedGitHubTokenLine: "GITHUB_TOKEN: ${{ secrets.SAFE_OUTPUTS_TOKEN }}", + shouldHaveGitHubToken: true, + }, { name: "add-comment without patches - no GITHUB_TOKEN override", frontmatter: map[string]any{ @@ -144,6 +162,27 @@ func TestHandlerManagerGitHubTokenEnvVarForCrossRepo(t *testing.T) { expectedGitHubTokenLine: "GITHUB_TOKEN: ${{ secrets.CREATE_PR_PAT }}", shouldHaveGitHubToken: true, }, + { + name: "create-pull-request head-github-app does not override checkout token", + frontmatter: map[string]any{ + "name": "Test Workflow", + "safe-outputs": map[string]any{ + "github-token": "${{ secrets.SAFE_OUTPUTS_TOKEN }}", + "create-pull-request": map[string]any{ + "head-github-app": map[string]any{ + "client-id": "${{ vars.HEAD_APP_ID }}", + "private-key": "${{ secrets.HEAD_APP_PRIVATE_KEY }}", + }, + "head-repo": "fork-owner/test-repo", + "max": 2, + }, + }, + }, + // head-github-app mints a fork-write credential and must not govern the checkout. + // The safe-outputs-level github-token should be used instead. + expectedGitHubTokenLine: "GITHUB_TOKEN: ${{ secrets.SAFE_OUTPUTS_TOKEN }}", + shouldHaveGitHubToken: true, + }, } for _, tt := range tests { diff --git a/pkg/workflow/safe_outputs_handler_registry.go b/pkg/workflow/safe_outputs_handler_registry.go index 70c732d89c9..851e345c861 100644 --- a/pkg/workflow/safe_outputs_handler_registry.go +++ b/pkg/workflow/safe_outputs_handler_registry.go @@ -513,6 +513,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableBool("auto_merge", c.AutoMerge). AddIfPositive("expires", c.Expires). AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddIfNotEmpty("head-repo", c.HeadRepoSlug). AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). AddTemplatableStringSlice("allowed_base_branches", c.AllowedBaseBranches). AddTemplatableStringSlice("allowed_branches", c.AllowedBranches). @@ -538,6 +539,13 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableBool("close_older_pull_requests", c.CloseOlderPullRequests). AddIfNotEmpty("close_older_key", c.CloseOlderKey). AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) + // Use app-minted token if head-github-app is configured; fall back to head-github-token. + if c.HeadGitHubApp != nil { + //nolint:gosec // G101: False positive - this is a GitHub Actions expression template, not a hardcoded credential + builder.AddIfNotEmpty("head-github-token", "${{ steps.safe-outputs-head-app-token.outputs.token }}") + } else { + builder.AddIfNotEmpty("head-github-token", c.HeadGitHubToken) + } return builder.Build() }, "push_to_pull_request_branch": func(cfg *SafeOutputsConfig) map[string]any { @@ -552,7 +560,7 @@ var handlerRegistry = map[string]handlerBuilder{ if c.MaxPatchSize > 0 { maxPatchSize = c.MaxPatchSize } - return newHandlerConfigBuilder(). + builder := newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddIfNotEmpty("target", c.Target). AddIfNotEmpty("title_prefix", c.TitlePrefix). @@ -562,6 +570,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("commit_title_suffix", c.CommitTitleSuffix). AddDefault("max_patch_size", maxPatchSize). AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddIfNotEmpty("head-repo", c.HeadRepoSlug). AddIfNotEmpty("base_branch", c.BaseBranch). AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). @@ -577,8 +586,15 @@ var handlerRegistry = map[string]handlerBuilder{ AddBoolPtr("fallback_as_pull_request", c.FallbackAsPullRequest). AddBoolPtr("signed_commits", c.SignedCommits). AddBoolPtr("check_branch_protection", c.CheckBranchProtection). - AddIfTrue("allow_workflows", c.AllowWorkflows). - Build() + AddIfTrue("allow_workflows", c.AllowWorkflows) + // Use app-minted token if head-github-app is configured; fall back to head-github-token. + if c.HeadGitHubApp != nil { + //nolint:gosec // G101: False positive - this is a GitHub Actions expression template, not a hardcoded credential + builder.AddIfNotEmpty("head-github-token", "${{ steps.safe-outputs-head-app-token.outputs.token }}") + } else { + builder.AddIfNotEmpty("head-github-token", c.HeadGitHubToken) + } + return builder.Build() }, "update_pull_request": func(cfg *SafeOutputsConfig) map[string]any { if cfg.UpdatePullRequests == nil {