diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs
index 43adbcfbe32..8f7b3991416 100644
--- a/actions/setup/js/create_pull_request.cjs
+++ b/actions/setup/js/create_pull_request.cjs
@@ -24,6 +24,7 @@ const { normalizeBranchName } = require("./normalize_branch_name.cjs");
const { pushExtraEmptyCommit } = require("./extra_empty_commit.cjs");
const { createCheckoutManager } = require("./dynamic_checkout.cjs");
const { closeOlderPullRequests } = require("./close_older_pull_requests.cjs");
+const { findRepoCheckout } = require("./find_repo_checkout.cjs");
const { getBaseBranch } = require("./get_base_branch.cjs");
const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs");
@@ -747,13 +748,20 @@ async function main(config = {}) {
// Track how many items we've processed for max limit
let processedCount = 0;
- // Create checkout manager for multi-repo support
+ // Multi-repo support: checkout mapping from compile-time checkout: configs.
+ // When target-repo: "*" is configured and repos are checked out into subdirectories,
+ // the checkout_mapping tells us where each repo lives on disk.
+ const checkoutMapping = config.checkout_mapping || null;
+
+ // Create checkout manager for multi-repo support (fallback when no checkout_mapping)
// Token is available via GITHUB_TOKEN environment variable (set by the workflow job)
const checkoutToken = process.env.GITHUB_TOKEN;
const checkoutManager = checkoutToken ? createCheckoutManager(checkoutToken, { defaultBaseBranch: configBaseBranch }) : null;
// Log multi-repo support status
- if (allowedRepos.size > 0 && checkoutManager) {
+ if (checkoutMapping) {
+ core.info(`Multi-repo support enabled via checkout mapping: ${Object.keys(checkoutMapping).length} repo(s) mapped`);
+ } else if (allowedRepos.size > 0 && checkoutManager) {
core.info(`Multi-repo support enabled: can switch between repos in allowed-repos list`);
} else if (allowedRepos.size > 0 && !checkoutManager) {
core.warning(`Multi-repo support disabled: GITHUB_TOKEN not available for dynamic checkout`);
@@ -866,172 +874,306 @@ async function main(config = {}) {
}
}
- // Multi-repo support: Switch checkout to target repo if different from current
- // This enables creating PRs in multiple repos from a single workflow run
- if (checkoutManager && itemRepo) {
- const switchResult = await checkoutManager.switchTo(itemRepo, { baseBranch });
- if (!switchResult.success) {
- core.warning(`Failed to switch to repository ${itemRepo}: ${switchResult.error}`);
+ // Multi-repo support: Switch to the correct working directory for the target repo.
+ // Priority:
+ // 1. checkout_mapping: repos checked out into subdirectories (wildcard target-repo)
+ // 2. findRepoCheckout: scan workspace for repo checkouts (subdirectory discovery)
+ // 3. createCheckoutManager: dynamic git remote switching (legacy allowed-repos)
+ let repoCwd = undefined;
+ const workflowRepo = process.env.GITHUB_REPOSITORY || "";
+ const isTargetingDifferentRepo = itemRepo && itemRepo.toLowerCase() !== workflowRepo.toLowerCase();
+
+ if (isTargetingDifferentRepo && checkoutMapping) {
+ // Use checkout mapping to find the subdirectory for this repo
+ const targetLower = itemRepo.toLowerCase();
+ const mappedPath = checkoutMapping[targetLower];
+ if (mappedPath) {
+ const absolutePath = require("path").resolve(process.env.GITHUB_WORKSPACE || process.cwd(), mappedPath);
+ repoCwd = absolutePath;
+ core.info(`Using checkout mapping: ${itemRepo} -> ${mappedPath}`);
+ } else {
+ // Repo not in mapping; try scanning workspace
+ const checkoutResult = findRepoCheckout(itemRepo, process.env.GITHUB_WORKSPACE, { allowedRepos: [...allowedRepos] });
+ if (checkoutResult.success) {
+ repoCwd = checkoutResult.path;
+ core.info(`Found checkout for ${itemRepo} via workspace scan at: ${repoCwd}`);
+ } else {
+ core.warning(`Repository ${itemRepo} not found in checkout mapping or workspace`);
+ return {
+ success: false,
+ error: `Repository '${itemRepo}' not found in workspace. Configure it in checkout: with a path to enable multi-repo PR creation.`,
+ };
+ }
+ }
+ } else if (isTargetingDifferentRepo && !checkoutMapping) {
+ // Legacy path: use findRepoCheckout first, fall back to dynamic checkout manager
+ const checkoutResult = findRepoCheckout(itemRepo, process.env.GITHUB_WORKSPACE, { allowedRepos: [...allowedRepos] });
+ if (checkoutResult.success) {
+ repoCwd = checkoutResult.path;
+ core.info(`Found checkout for ${itemRepo} at: ${repoCwd}`);
+ } else if (checkoutManager) {
+ // Fall back to dynamic remote switching (changes git remote in workspace root)
+ const switchResult = await checkoutManager.switchTo(itemRepo, { baseBranch });
+ if (!switchResult.success) {
+ core.warning(`Failed to switch to repository ${itemRepo}: ${switchResult.error}`);
+ return {
+ success: false,
+ error: `Failed to checkout repository ${itemRepo}: ${switchResult.error}`,
+ };
+ }
+ if (switchResult.switched) {
+ core.info(`Switched checkout to repository: ${itemRepo} (dynamic remote switch)`);
+ }
+ } else {
+ // No checkout found and no checkout manager available.
+ // Proceed without repoCwd — if a patch/bundle is required it will fail at apply time;
+ // if allow_empty is set the PR can still be created via API alone.
+ core.warning(`Repository '${itemRepo}' not found in workspace and no checkout manager available; proceeding from workspace root`);
+ }
+ }
+
+ // If we have a repoCwd (subdirectory checkout), change process cwd for git operations
+ const originalCwd = process.cwd();
+ if (repoCwd) {
+ try {
+ process.chdir(repoCwd);
+ } catch (chdirError) {
+ return { success: false, error: `Failed to change working directory to '${repoCwd}': ${getErrorMessage(chdirError)}` };
+ }
+ core.info(`Changed working directory to: ${repoCwd}`);
+ }
+
+ try {
+ // SECURITY: Sanitize dynamically resolved base branch to prevent shell injection
+ const originalBaseBranch = baseBranch;
+ baseBranch = normalizeBranchName(baseBranch);
+ if (!baseBranch) {
+ return {
+ success: false,
+ error: `Invalid base branch: sanitization resulted in empty string (original: "${originalBaseBranch}")`,
+ };
+ }
+ if (originalBaseBranch !== baseBranch) {
return {
success: false,
- error: `Failed to checkout repository ${itemRepo}: ${switchResult.error}`,
+ error: `Invalid base branch: contains invalid characters (original: "${originalBaseBranch}", normalized: "${baseBranch}")`,
};
}
- if (switchResult.switched) {
- core.info(`Switched checkout to repository: ${itemRepo}`);
+ core.info(`Base branch for ${itemRepo}: ${baseBranch}`);
+
+ // Check if patch file exists and has valid content.
+ // Always require patch content for policy enforcement, even when bundle transport
+ // is used for apply-time commit transport.
+ const hasBundleFile = !!(bundleFilePath && fs.existsSync(bundleFilePath));
+ const applyTransport = hasBundleFile ? "bundle" : "patch";
+ core.info(`Apply transport mode: ${applyTransport} (bundle file present: ${hasBundleFile})`);
+ if (bundleFilePath && !hasBundleFile) {
+ core.warning(`Bundle file path was provided but file is not present on disk: ${bundleFilePath}; falling back to patch transport`);
}
- }
+ const hasPatchFile = !!(patchFilePath && fs.existsSync(patchFilePath));
+ if (!hasPatchFile) {
+ // If allow-empty is enabled, we can proceed without a patch file
+ if (allowEmpty) {
+ core.info("No patch file found, but allow-empty is enabled - will create empty PR");
+ } else {
+ const message = "No patch file found - cannot create pull request without changes";
+
+ // If in staged mode, still show preview
+ if (isStaged) {
+ let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n";
+ summaryContent += "The following pull request would be created if staged mode was disabled:\n\n";
+ summaryContent += `**Status:** ⚠️ No patch file found\n\n`;
+ summaryContent += `**Message:** ${message}\n\n`;
+
+ // Write to step summary
+ await core.summary.addRaw(summaryContent).write();
+ core.info("📝 Pull request creation preview written to step summary (no patch file)");
+ return { success: true, staged: true };
+ }
- // SECURITY: Sanitize dynamically resolved base branch to prevent shell injection
- const originalBaseBranch = baseBranch;
- baseBranch = normalizeBranchName(baseBranch);
- if (!baseBranch) {
- return {
- success: false,
- error: `Invalid base branch: sanitization resulted in empty string (original: "${originalBaseBranch}")`,
- };
- }
- if (originalBaseBranch !== baseBranch) {
- return {
- success: false,
- error: `Invalid base branch: contains invalid characters (original: "${originalBaseBranch}", normalized: "${baseBranch}")`,
- };
- }
- core.info(`Base branch for ${itemRepo}: ${baseBranch}`);
-
- // Check if patch file exists and has valid content.
- // Always require patch content for policy enforcement, even when bundle transport
- // is used for apply-time commit transport.
- const hasBundleFile = !!(bundleFilePath && fs.existsSync(bundleFilePath));
- const applyTransport = hasBundleFile ? "bundle" : "patch";
- core.info(`Apply transport mode: ${applyTransport} (bundle file present: ${hasBundleFile})`);
- if (bundleFilePath && !hasBundleFile) {
- core.warning(`Bundle file path was provided but file is not present on disk: ${bundleFilePath}; falling back to patch transport`);
- }
- const hasPatchFile = !!(patchFilePath && fs.existsSync(patchFilePath));
- if (!hasPatchFile) {
- // If allow-empty is enabled, we can proceed without a patch file
- if (allowEmpty) {
- core.info("No patch file found, but allow-empty is enabled - will create empty PR");
- } else {
- const message = "No patch file found - cannot create pull request without changes";
+ switch (ifNoChanges) {
+ case "error":
+ return { success: false, error: message };
+
+ case "ignore":
+ // Silent success - no console output
+ return { success: false, skipped: true };
+
+ case "warn":
+ default:
+ core.warning(message);
+ return { success: false, error: message, skipped: true };
+ }
+ }
+ }
+
+ let patchContent = "";
+ let isEmpty = true;
+ if (hasPatchFile) {
+ patchContent = fs.readFileSync(patchFilePath, "utf8");
+ isEmpty = !patchContent || !patchContent.trim();
+ }
- // If in staged mode, still show preview
+ // Enforce max limits on patch before processing.
+ // Count files once here so the catch block can reuse the value without re-parsing.
+ const patchFileCount = countUniquePatchFiles(patchContent);
+ try {
+ enforcePullRequestLimits(patchContent, maxFiles);
+ } catch (error) {
+ const errorMessage = getErrorMessage(error);
+ core.warning(`Pull request limit exceeded: ${errorMessage}`);
+
+ // In staged mode, show a preview instead of performing API side effects
if (isStaged) {
let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n";
summaryContent += "The following pull request would be created if staged mode was disabled:\n\n";
- summaryContent += `**Status:** ⚠️ No patch file found\n\n`;
- summaryContent += `**Message:** ${message}\n\n`;
+ summaryContent += `**Status:** ⚠️ Patch file limit exceeded\n\n`;
+ summaryContent += `**Message:** ${errorMessage}\n\n`;
- // Write to step summary
await core.summary.addRaw(summaryContent).write();
- core.info("📝 Pull request creation preview written to step summary (no patch file)");
+ core.info("📝 Pull request creation preview written to step summary (file limit exceeded)");
return { success: true, staged: true };
}
- switch (ifNoChanges) {
- case "error":
- return { success: false, error: message };
+ if (!fallbackAsIssue) {
+ return { success: false, error: errorMessage };
+ }
- case "ignore":
- // Silent success - no console output
- return { success: false, skipped: true };
+ // Surface the limit error in a fallback issue so it appears in the agent failure
+ // issue/comment thread and the workflow operator knows exactly how to fix it.
+ const rawFallbackTitle = pullRequestItem.title?.trim() || "Agent Output";
+ const fallbackTitle = applyTitlePrefix(sanitizeTitle(rawFallbackTitle, titlePrefix), titlePrefix);
+ const fallbackLabels = mergeFallbackIssueLabels(configFallbackLabels.length > 0 ? configFallbackLabels : envLabels);
+ const fallbackTemplatePath = getPromptPath("e003_file_limit_fallback.md");
+ const fallbackBody = renderTemplateFromFile(fallbackTemplatePath, {
+ error_message: errorMessage,
+ suggested_limit: patchFileCount,
+ });
- case "warn":
- default:
- core.warning(message);
- return { success: false, error: message, skipped: true };
+ try {
+ const { data: issue } = await createFallbackIssue(githubClient, repoParts, fallbackTitle, fallbackBody, fallbackLabels, configAssignees);
+ core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);
+ await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number);
+ await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue");
+ return {
+ success: true,
+ fallback_used: true,
+ issue_number: issue.number,
+ issue_url: issue.html_url,
+ };
+ } catch (issueError) {
+ const combinedError = `Pull request limit exceeded and failed to create fallback issue. Limit error: ${errorMessage}. Issue error: ${getErrorMessage(issueError)}`;
+ core.error(combinedError);
+ return { success: false, error: combinedError };
}
}
- }
- let patchContent = "";
- let isEmpty = true;
- if (hasPatchFile) {
- patchContent = fs.readFileSync(patchFilePath, "utf8");
- isEmpty = !patchContent || !patchContent.trim();
- }
+ // Check for actual error conditions (but allow empty patches as valid noop)
+ if (patchContent.includes("Failed to generate patch")) {
+ // If allow-empty is enabled, ignore patch errors and proceed
+ if (allowEmpty) {
+ core.info("Patch file contains error, but allow-empty is enabled - will create empty PR");
+ patchContent = "";
+ isEmpty = true;
+ } else {
+ const message = "Patch file contains error message - cannot create pull request without changes";
+
+ // If in staged mode, still show preview
+ if (isStaged) {
+ let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n";
+ summaryContent += "The following pull request would be created if staged mode was disabled:\n\n";
+ summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`;
+ summaryContent += `**Message:** ${message}\n\n`;
+
+ // Write to step summary
+ await core.summary.addRaw(summaryContent).write();
+ core.info("📝 Pull request creation preview written to step summary (patch error)");
+ return { success: true, staged: true };
+ }
- // Enforce max limits on patch before processing.
- // Count files once here so the catch block can reuse the value without re-parsing.
- const patchFileCount = countUniquePatchFiles(patchContent);
- try {
- enforcePullRequestLimits(patchContent, maxFiles);
- } catch (error) {
- const errorMessage = getErrorMessage(error);
- core.warning(`Pull request limit exceeded: ${errorMessage}`);
+ switch (ifNoChanges) {
+ case "error":
+ return { success: false, error: message };
- // In staged mode, show a preview instead of performing API side effects
- if (isStaged) {
- let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n";
- summaryContent += "The following pull request would be created if staged mode was disabled:\n\n";
- summaryContent += `**Status:** ⚠️ Patch file limit exceeded\n\n`;
- summaryContent += `**Message:** ${errorMessage}\n\n`;
+ case "ignore":
+ // Silent success - no console output
+ return { success: false, skipped: true };
- await core.summary.addRaw(summaryContent).write();
- core.info("📝 Pull request creation preview written to step summary (file limit exceeded)");
- return { success: true, staged: true };
+ case "warn":
+ default:
+ core.warning(message);
+ return { success: false, error: message, skipped: true };
+ }
+ }
}
- if (!fallbackAsIssue) {
- return { success: false, error: errorMessage };
- }
+ // Validate patch size (unless empty)
+ if (!isEmpty) {
+ // maxSizeKb is already extracted from config at the top
+ const patchSizeBytes = Buffer.byteLength(patchContent, "utf8");
+ const patchSizeKb = Math.ceil(patchSizeBytes / 1024);
+
+ core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`);
+
+ if (patchSizeKb > maxSizeKb) {
+ const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`;
+
+ // If in staged mode, still show preview with error
+ if (isStaged) {
+ let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n";
+ summaryContent += "The following pull request would be created if staged mode was disabled:\n\n";
+ summaryContent += `**Status:** ❌ Patch size exceeded\n\n`;
+ summaryContent += `**Message:** ${message}\n\n`;
+
+ // Write to step summary
+ await core.summary.addRaw(summaryContent).write();
+ core.info("📝 Pull request creation preview written to step summary (patch size error)");
+ return { success: true, staged: true };
+ }
- // Surface the limit error in a fallback issue so it appears in the agent failure
- // issue/comment thread and the workflow operator knows exactly how to fix it.
- const rawFallbackTitle = pullRequestItem.title?.trim() || "Agent Output";
- const fallbackTitle = applyTitlePrefix(sanitizeTitle(rawFallbackTitle, titlePrefix), titlePrefix);
- const fallbackLabels = mergeFallbackIssueLabels(configFallbackLabels.length > 0 ? configFallbackLabels : envLabels);
- const fallbackTemplatePath = getPromptPath("e003_file_limit_fallback.md");
- const fallbackBody = renderTemplateFromFile(fallbackTemplatePath, {
- error_message: errorMessage,
- suggested_limit: patchFileCount,
- });
+ return { success: false, error: message };
+ }
- try {
- const { data: issue } = await createFallbackIssue(githubClient, repoParts, fallbackTitle, fallbackBody, fallbackLabels, configAssignees);
- core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);
- await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number);
- await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue");
- return {
- success: true,
- fallback_used: true,
- issue_number: issue.number,
- issue_url: issue.html_url,
- };
- } catch (issueError) {
- const combinedError = `Pull request limit exceeded and failed to create fallback issue. Limit error: ${errorMessage}. Issue error: ${getErrorMessage(issueError)}`;
- core.error(combinedError);
- return { success: false, error: combinedError };
+ core.info("Patch size validation passed");
}
- }
-
- // Check for actual error conditions (but allow empty patches as valid noop)
- if (patchContent.includes("Failed to generate patch")) {
- // If allow-empty is enabled, ignore patch errors and proceed
- if (allowEmpty) {
- core.info("Patch file contains error, but allow-empty is enabled - will create empty PR");
- patchContent = "";
- isEmpty = true;
- } else {
- const message = "Patch file contains error message - cannot create pull request without changes";
-
- // If in staged mode, still show preview
- if (isStaged) {
- let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n";
- summaryContent += "The following pull request would be created if staged mode was disabled:\n\n";
- summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`;
- summaryContent += `**Message:** ${message}\n\n`;
- // Write to step summary
- await core.summary.addRaw(summaryContent).write();
- core.info("📝 Pull request creation preview written to step summary (patch error)");
- return { success: true, staged: true };
+ // Check file protection: allowlist (strict) or protected-files policy.
+ /** @type {string[] | null} Protected files that trigger fallback-to-issue handling */
+ let manifestProtectionFallback = null;
+ /** @type {string[] | null} Protected files that trigger request-review handling */
+ let manifestProtectionRequestReview = null;
+ /** @type {unknown} */
+ let manifestProtectionPushFailedError = null;
+ if (!isEmpty) {
+ const protection = checkFileProtection(patchContent, {
+ ...config,
+ protected_files_policy: config.protected_files_policy ?? "request_review",
+ });
+ if (protection.action === "deny") {
+ const filesStr = protection.files.join(", ");
+ const message =
+ protection.source === "allowlist"
+ ? `Cannot create pull request: patch modifies files outside the allowed-files list (${filesStr}). Add the files to the allowed-files configuration field or remove them from the patch.`
+ : `Cannot create pull request: patch modifies protected files (${filesStr}). Add them to the allowed-files configuration field or set protected-files: fallback-to-issue to create a review issue instead.`;
+ core.error(message);
+ return { success: false, error: message };
}
+ if (protection.action === "fallback") {
+ manifestProtectionFallback = protection.files;
+ core.warning(`Protected file protection triggered (fallback-to-issue): ${protection.files.join(", ")}. Will create review issue instead of pull request.`);
+ }
+ if (protection.action === "request_review") {
+ manifestProtectionRequestReview = protection.files;
+ core.warning(`Protected file protection triggered (request_review): ${protection.files.join(", ")}. Will create pull request with caution and request-changes review.`);
+ }
+ }
+
+ if (isEmpty && !isStaged && !allowEmpty) {
+ const message = "Patch file is empty - no changes to apply (noop operation)";
switch (ifNoChanges) {
case "error":
- return { success: false, error: message };
+ return { success: false, error: "No changes to push - failing as configured by if-no-changes: error" };
case "ignore":
// Silent success - no console output
@@ -1043,485 +1185,405 @@ async function main(config = {}) {
return { success: false, error: message, skipped: true };
}
}
- }
- // Validate patch size (unless empty)
- if (!isEmpty) {
- // maxSizeKb is already extracted from config at the top
- const patchSizeBytes = Buffer.byteLength(patchContent, "utf8");
- const patchSizeKb = Math.ceil(patchSizeBytes / 1024);
+ if (!isEmpty) {
+ core.info("Patch content validation passed");
+ } else if (allowEmpty) {
+ core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)");
+ } else {
+ core.info("Patch file is empty - processing noop operation");
+ }
- core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`);
+ // If in staged mode, emit step summary instead of creating PR
+ if (isStaged) {
+ let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n";
+ summaryContent += "The following pull request would be created if staged mode was disabled:\n\n";
- if (patchSizeKb > maxSizeKb) {
- const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`;
+ summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`;
+ summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`;
+ summaryContent += `**Base:** ${baseBranch}\n\n`;
- // If in staged mode, still show preview with error
- if (isStaged) {
- let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n";
- summaryContent += "The following pull request would be created if staged mode was disabled:\n\n";
- summaryContent += `**Status:** ❌ Patch size exceeded\n\n`;
- summaryContent += `**Message:** ${message}\n\n`;
+ if (pullRequestItem.body) {
+ summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`;
+ }
- // Write to step summary
- await core.summary.addRaw(summaryContent).write();
- core.info("📝 Pull request creation preview written to step summary (patch size error)");
- return { success: true, staged: true };
+ if (patchFilePath && fs.existsSync(patchFilePath)) {
+ const patchStats = fs.readFileSync(patchFilePath, "utf8");
+ if (patchStats.trim()) {
+ summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`;
+ summaryContent += `Show patch preview
\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n \n\n`;
+ } else {
+ summaryContent += `**Changes:** No changes (empty patch)\n\n`;
+ }
}
- return { success: false, error: message };
+ // Write to step summary
+ await core.summary.addRaw(summaryContent).write();
+ core.info("📝 Pull request creation preview written to step summary");
+ return { success: true, staged: true };
}
- core.info("Patch size validation passed");
- }
-
- // Check file protection: allowlist (strict) or protected-files policy.
- /** @type {string[] | null} Protected files that trigger fallback-to-issue handling */
- let manifestProtectionFallback = null;
- /** @type {string[] | null} Protected files that trigger request-review handling */
- let manifestProtectionRequestReview = null;
- /** @type {unknown} */
- let manifestProtectionPushFailedError = null;
- if (!isEmpty) {
- const protection = checkFileProtection(patchContent, {
- ...config,
- protected_files_policy: config.protected_files_policy ?? "request_review",
- });
- if (protection.action === "deny") {
- const filesStr = protection.files.join(", ");
- const message =
- protection.source === "allowlist"
- ? `Cannot create pull request: patch modifies files outside the allowed-files list (${filesStr}). Add the files to the allowed-files configuration field or remove them from the patch.`
- : `Cannot create pull request: patch modifies protected files (${filesStr}). Add them to the allowed-files configuration field or set protected-files: fallback-to-issue to create a review issue instead.`;
- core.error(message);
- return { success: false, error: message };
+ // Extract title, body, and branch from the JSON item
+ let title = pullRequestItem.title.trim();
+ let processedBody = pullRequestItem.body;
+
+ // Replace temporary ID references in the body with resolved issue/PR numbers
+ // This allows PRs to reference issues created earlier in the same workflow
+ // by using temporary IDs like #aw_123abc456def
+ if (resolvedTemporaryIds && Object.keys(resolvedTemporaryIds).length > 0) {
+ // Convert object to Map for compatibility with replaceTemporaryIdReferences
+ const tempIdMap = new Map(Object.entries(resolvedTemporaryIds));
+ processedBody = replaceTemporaryIdReferences(processedBody, tempIdMap, itemRepo);
+ core.info(`Resolved ${tempIdMap.size} temporary ID references in PR body`);
}
- if (protection.action === "fallback") {
- manifestProtectionFallback = protection.files;
- core.warning(`Protected file protection triggered (fallback-to-issue): ${protection.files.join(", ")}. Will create review issue instead of pull request.`);
- }
- if (protection.action === "request_review") {
- manifestProtectionRequestReview = protection.files;
- core.warning(`Protected file protection triggered (request_review): ${protection.files.join(", ")}. Will create pull request with caution and request-changes review.`);
- }
- }
-
- if (isEmpty && !isStaged && !allowEmpty) {
- const message = "Patch file is empty - no changes to apply (noop operation)";
- switch (ifNoChanges) {
- case "error":
- return { success: false, error: "No changes to push - failing as configured by if-no-changes: error" };
+ // Remove duplicate title from description if it starts with a header matching the title
+ processedBody = removeDuplicateTitleFromDescription(title, processedBody);
+
+ // Sanitize body content to neutralize @mentions, URLs, and other security risks
+ processedBody = sanitizeContent(processedBody, { allowedAliases: allowedMentionAliases });
+
+ // Auto-add "Fixes #N" closing keyword if triggered from an issue and not already present.
+ // This ensures the triggering issue is auto-closed when the PR is merged.
+ // Agents are instructed to include this but don't reliably do so.
+ // This behavior can be disabled by setting auto-close-issue: false in the workflow config.
+ if (triggeringIssueNumber && autoCloseIssue) {
+ const hasClosingKeyword = /(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+#\d+/i.test(processedBody);
+ if (!hasClosingKeyword) {
+ processedBody = processedBody.trimEnd() + `\n\n- Fixes #${triggeringIssueNumber}`;
+ core.info(`Auto-added "Fixes #${triggeringIssueNumber}" closing keyword to PR body as bullet point`);
+ }
+ } else if (triggeringIssueNumber && !autoCloseIssue) {
+ core.info(`Skipping auto-close keyword for #${triggeringIssueNumber} (auto-close-issue: false)`);
+ }
- case "ignore":
- // Silent success - no console output
- return { success: false, skipped: true };
+ let bodyLines = processedBody.split("\n");
+ let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null;
+ // Preserve the original agent branch name for bundle transport (the bundle was created
+ // using this branch name as the refs/heads ref inside the bundle file).
+ const originalAgentBranch = branchName;
+ const randomHex = crypto.randomBytes(8).toString("hex");
+
+ // SECURITY: Sanitize branch name to prevent shell injection (CWE-78)
+ // Branch names from user input must be normalized before use in git commands.
+ // When preserve-branch-name is disabled (default), a random salt suffix is
+ // appended to avoid collisions.
+ if (branchName) {
+ const originalBranchName = branchName;
+ branchName = normalizeBranchName(branchName, preserveBranchName ? null : randomHex);
+
+ // Validate it's not empty after normalization
+ if (!branchName) {
+ throw new Error(`Invalid branch name: sanitization resulted in empty string (original: "${originalBranchName}")`);
+ }
- case "warn":
- default:
- core.warning(message);
- return { success: false, error: message, skipped: true };
+ if (preserveBranchName) {
+ core.info(`Using branch name from JSONL without salt suffix (preserve-branch-name enabled): ${branchName}`);
+ } else {
+ core.info(`Using branch name from JSONL with added salt: ${branchName}`);
+ }
+ if (originalBranchName !== branchName) {
+ core.info(`Branch name sanitized: "${originalBranchName}" -> "${branchName}"`);
+ }
}
- }
- if (!isEmpty) {
- core.info("Patch content validation passed");
- } else if (allowEmpty) {
- core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)");
- } else {
- core.info("Patch file is empty - processing noop operation");
- }
+ // If no title was found, use a default
+ if (!title) {
+ title = "Agent Output";
+ }
- // If in staged mode, emit step summary instead of creating PR
- if (isStaged) {
- let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n";
- summaryContent += "The following pull request would be created if staged mode was disabled:\n\n";
+ // Sanitize title for Unicode security and remove any duplicate prefixes
+ title = sanitizeTitle(title, titlePrefix);
+
+ // Apply title prefix (only if it doesn't already exist)
+ title = applyTitlePrefix(title, titlePrefix);
+
+ // Add AI disclaimer with workflow name and run url
+ const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
+ const workflowId = process.env.GH_AW_WORKFLOW_ID || "";
+ const runUrl = buildWorkflowRunUrl(context, context.repo);
+ const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE ?? "";
+ const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL ?? "";
+ const triggeringPRNumber = context.payload.pull_request?.number;
+ const triggeringDiscussionNumber = context.payload.discussion?.number;
+
+ // Prepend threat detection caution alert at the very top of the PR body so it is
+ // immediately visible to reviewers. The caution is omitted from the footer to
+ // avoid duplication (skipDetectionCaution is passed to generateFooterWithMessages).
+
+ // Inject body header before user content (unshifted first, so caution will appear before it)
+ const bodyHeader = getBodyHeader({ workflowName, runUrl });
+ if (bodyHeader) {
+ bodyLines.unshift(...bodyHeader.split("\n"), "");
+ }
- summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`;
- summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`;
- summaryContent += `**Base:** ${baseBranch}\n\n`;
+ // Keep the protected-files notice directly under detection caution:
+ // this block runs first, then detectionCaution below unshifts to index 0.
+ if (manifestProtectionRequestReview && manifestProtectionRequestReview.length > 0) {
+ const protectedFilesNoticeTemplatePath = getPromptPath("manifest_protection_request_review.md");
+ const protectedFilesNotice = renderTemplateFromFile(protectedFilesNoticeTemplatePath, {
+ files: renderFilesList(manifestProtectionRequestReview.join(", ")),
+ });
+ bodyLines.unshift(protectedFilesNotice, "", "");
+ }
+ // Inject CAUTION at top of body (unshifted after header so it appears first in the final output)
+ const detectionCaution = assembleMarkdownBodyParts({
+ includeFooter: false,
+ workflowName,
+ runUrl,
+ }).detectionCaution;
+ if (detectionCaution) {
+ // unshift(caution, "", "") places the caution alert at index 0 and two blank
+ // separator lines so the main body content follows after a full empty line.
+ bodyLines.unshift(detectionCaution, "", "");
+ }
- if (pullRequestItem.body) {
- summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`;
+ // Add fingerprint comment if present
+ const trackerIDComment = getTrackerID("markdown");
+ if (trackerIDComment) {
+ bodyLines.push(trackerIDComment);
}
- if (patchFilePath && fs.existsSync(patchFilePath)) {
- const patchStats = fs.readFileSync(patchFilePath, "utf8");
- if (patchStats.trim()) {
- summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`;
- summaryContent += `Show patch preview
\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n \n\n`;
- } else {
- summaryContent += `**Changes:** No changes (empty patch)\n\n`;
+ // Snapshot the body content (without footer) for use in protected-files fallback ordering.
+ // The protected-files section must appear before the footer (including guard notices such as
+ // the integrity-filtering note) so that the footer always comes last in the issue body.
+ const mainBodyContent = bodyLines.join("\n").trim();
+ const issueSafeMainBodyContent = neutralizeClosingKeywordsForIssueBody(mainBodyContent);
+
+ // Generate footer using messages template system (respects custom messages.footer config)
+ // When footer is disabled, only add XML markers (no visible footer content)
+ const footerParts = [];
+ if (includeFooter) {
+ const historyUrl =
+ generateHistoryUrl({
+ owner: repoParts.owner,
+ repo: repoParts.repo,
+ itemType: "pull_request",
+ workflowId,
+ serverUrl: context.serverUrl,
+ }) ?? undefined;
+ // The footer builder skips detection caution so the caution already prepended at
+ // the top of the body is not duplicated in the footer.
+ let footer = assembleMarkdownBodyParts({
+ includeFooter: true,
+ workflowName,
+ runUrl,
+ workflowSource,
+ workflowSourceURL,
+ triggeringIssueNumber,
+ triggeringPRNumber,
+ triggeringDiscussionNumber,
+ historyUrl,
+ }).footer;
+ footer = addExpirationToFooter(footer, expiresHours, "Pull Request");
+ if (expiresHours > 0) {
+ footer += "\n\n";
}
+ bodyLines.push(``, ``, footer);
+ footerParts.push(footer);
}
- // Write to step summary
- await core.summary.addRaw(summaryContent).write();
- core.info("📝 Pull request creation preview written to step summary");
- return { success: true, staged: true };
- }
-
- // Extract title, body, and branch from the JSON item
- let title = pullRequestItem.title.trim();
- let processedBody = pullRequestItem.body;
-
- // Replace temporary ID references in the body with resolved issue/PR numbers
- // This allows PRs to reference issues created earlier in the same workflow
- // by using temporary IDs like #aw_123abc456def
- if (resolvedTemporaryIds && Object.keys(resolvedTemporaryIds).length > 0) {
- // Convert object to Map for compatibility with replaceTemporaryIdReferences
- const tempIdMap = new Map(Object.entries(resolvedTemporaryIds));
- processedBody = replaceTemporaryIdReferences(processedBody, tempIdMap, itemRepo);
- core.info(`Resolved ${tempIdMap.size} temporary ID references in PR body`);
- }
-
- // Remove duplicate title from description if it starts with a header matching the title
- processedBody = removeDuplicateTitleFromDescription(title, processedBody);
-
- // Sanitize body content to neutralize @mentions, URLs, and other security risks
- processedBody = sanitizeContent(processedBody, { allowedAliases: allowedMentionAliases });
-
- // Auto-add "Fixes #N" closing keyword if triggered from an issue and not already present.
- // This ensures the triggering issue is auto-closed when the PR is merged.
- // Agents are instructed to include this but don't reliably do so.
- // This behavior can be disabled by setting auto-close-issue: false in the workflow config.
- if (triggeringIssueNumber && autoCloseIssue) {
- const hasClosingKeyword = /(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+#\d+/i.test(processedBody);
- if (!hasClosingKeyword) {
- processedBody = processedBody.trimEnd() + `\n\n- Fixes #${triggeringIssueNumber}`;
- core.info(`Auto-added "Fixes #${triggeringIssueNumber}" closing keyword to PR body as bullet point`);
+ // Add standalone workflow-id marker for searchability (consistent with comments)
+ // Always add XML markers even when footer is disabled
+ if (workflowId) {
+ const workflowIdMarker = generateWorkflowIdMarker(workflowId);
+ // Add to bodyLines for the normal PR body path.
+ // Add to footerParts so the fallback issue body places it after the protected-files section.
+ bodyLines.push(``, workflowIdMarker);
+ footerParts.push(workflowIdMarker);
}
- } else if (triggeringIssueNumber && !autoCloseIssue) {
- core.info(`Skipping auto-close keyword for #${triggeringIssueNumber} (auto-close-issue: false)`);
- }
- let bodyLines = processedBody.split("\n");
- let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null;
- // Preserve the original agent branch name for bundle transport (the bundle was created
- // using this branch name as the refs/heads ref inside the bundle file).
- const originalAgentBranch = branchName;
- const randomHex = crypto.randomBytes(8).toString("hex");
-
- // SECURITY: Sanitize branch name to prevent shell injection (CWE-78)
- // Branch names from user input must be normalized before use in git commands.
- // When preserve-branch-name is disabled (default), a random salt suffix is
- // appended to avoid collisions.
- if (branchName) {
- const originalBranchName = branchName;
- branchName = normalizeBranchName(branchName, preserveBranchName ? null : randomHex);
-
- // Validate it's not empty after normalization
- if (!branchName) {
- throw new Error(`Invalid branch name: sanitization resulted in empty string (original: "${originalBranchName}")`);
+ // Embed gh-aw-workflow-call-id marker so callers sharing the same reusable workflow
+ // do not close each other's PRs when close-older-pull-requests is enabled.
+ if (callerWorkflowId) {
+ bodyLines.push(generateWorkflowCallIdMarker(callerWorkflowId));
}
- if (preserveBranchName) {
- core.info(`Using branch name from JSONL without salt suffix (preserve-branch-name enabled): ${branchName}`);
- } else {
- core.info(`Using branch name from JSONL with added salt: ${branchName}`);
+ // Embed gh-aw-close-key marker when an explicit deduplication key is set.
+ if (closeOlderKey) {
+ bodyLines.push(generateCloseKeyMarker(closeOlderKey));
}
- if (originalBranchName !== branchName) {
- core.info(`Branch name sanitized: "${originalBranchName}" -> "${branchName}"`);
- }
- }
- // If no title was found, use a default
- if (!title) {
- title = "Agent Output";
- }
+ bodyLines.push("");
- // Sanitize title for Unicode security and remove any duplicate prefixes
- title = sanitizeTitle(title, titlePrefix);
-
- // Apply title prefix (only if it doesn't already exist)
- title = applyTitlePrefix(title, titlePrefix);
-
- // Add AI disclaimer with workflow name and run url
- const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
- const workflowId = process.env.GH_AW_WORKFLOW_ID || "";
- const runUrl = buildWorkflowRunUrl(context, context.repo);
- const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE ?? "";
- const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL ?? "";
- const triggeringPRNumber = context.payload.pull_request?.number;
- const triggeringDiscussionNumber = context.payload.discussion?.number;
-
- // Prepend threat detection caution alert at the very top of the PR body so it is
- // immediately visible to reviewers. The caution is omitted from the footer to
- // avoid duplication (skipDetectionCaution is passed to generateFooterWithMessages).
-
- // Inject body header before user content (unshifted first, so caution will appear before it)
- const bodyHeader = getBodyHeader({ workflowName, runUrl });
- if (bodyHeader) {
- bodyLines.unshift(...bodyHeader.split("\n"), "");
- }
+ // Prepare the body content
+ const body = bodyLines.join("\n").trim();
+ const issueSafeBody = neutralizeClosingKeywordsForIssueBody(body);
+ // Footer section (footer + workflow-id marker) used when ordering protected-files notices
+ const footerContent = footerParts.join("\n\n");
- // Keep the protected-files notice directly under detection caution:
- // this block runs first, then detectionCaution below unshifts to index 0.
- if (manifestProtectionRequestReview && manifestProtectionRequestReview.length > 0) {
- const protectedFilesNoticeTemplatePath = getPromptPath("manifest_protection_request_review.md");
- const protectedFilesNotice = renderTemplateFromFile(protectedFilesNoticeTemplatePath, {
- files: renderFilesList(manifestProtectionRequestReview.join(", ")),
- });
- bodyLines.unshift(protectedFilesNotice, "", "");
- }
- // Inject CAUTION at top of body (unshifted after header so it appears first in the final output)
- const detectionCaution = assembleMarkdownBodyParts({
- includeFooter: false,
- workflowName,
- runUrl,
- }).detectionCaution;
- if (detectionCaution) {
- // unshift(caution, "", "") places the caution alert at index 0 and two blank
- // separator lines so the main body content follows after a full empty line.
- bodyLines.unshift(detectionCaution, "", "");
- }
+ // Build labels array - merge config labels with message labels
+ let labels = [...envLabels];
+ if (pullRequestItem.labels && Array.isArray(pullRequestItem.labels)) {
+ labels = [...labels, ...pullRequestItem.labels];
+ }
+ labels = labels
+ .filter(label => !!label)
+ .map(label => String(label).trim())
+ .filter(label => label);
+ // Add agentic-threat-detected label when threat detection produced a warning
+ if (detectionCaution && !labels.includes("agentic-threat-detected")) {
+ labels.push("agentic-threat-detected");
+ }
+ // Use explicitly configured fallback labels when present; otherwise preserve
+ // existing behavior by reusing pull request labels for fallback issues.
+ const effectiveFallbackLabels = configFallbackLabels.length > 0 ? configFallbackLabels : labels;
+
+ // Configuration enforces draft as a policy, not a fallback (consistent with autoMerge/allowEmpty)
+ const draft = draftDefault;
+ if (pullRequestItem.draft !== undefined && pullRequestItem.draft !== draftDefault) {
+ core.warning(
+ `Agent requested draft: ${pullRequestItem.draft}, but configuration enforces draft: ${draftDefault}. ` +
+ `Configuration takes precedence for security. To change this, update safe-outputs.create-pull-request.draft in the workflow file.`
+ );
+ }
- // Add fingerprint comment if present
- const trackerIDComment = getTrackerID("markdown");
- if (trackerIDComment) {
- bodyLines.push(trackerIDComment);
- }
+ core.info(`Creating pull request with title: ${title}`);
+ core.info(`Labels: ${JSON.stringify(labels)}`);
+ core.info(`Draft: ${draft}`);
+ core.info(`Body length: ${body.length}`);
- // Snapshot the body content (without footer) for use in protected-files fallback ordering.
- // The protected-files section must appear before the footer (including guard notices such as
- // the integrity-filtering note) so that the footer always comes last in the issue body.
- const mainBodyContent = bodyLines.join("\n").trim();
- const issueSafeMainBodyContent = neutralizeClosingKeywordsForIssueBody(mainBodyContent);
-
- // Generate footer using messages template system (respects custom messages.footer config)
- // When footer is disabled, only add XML markers (no visible footer content)
- const footerParts = [];
- if (includeFooter) {
- const historyUrl =
- generateHistoryUrl({
- owner: repoParts.owner,
- repo: repoParts.repo,
- itemType: "pull_request",
- workflowId,
- serverUrl: context.serverUrl,
- }) ?? undefined;
- // The footer builder skips detection caution so the caution already prepended at
- // the top of the body is not duplicated in the footer.
- let footer = assembleMarkdownBodyParts({
- includeFooter: true,
- workflowName,
- runUrl,
- workflowSource,
- workflowSourceURL,
- triggeringIssueNumber,
- triggeringPRNumber,
- triggeringDiscussionNumber,
- historyUrl,
- }).footer;
- footer = addExpirationToFooter(footer, expiresHours, "Pull Request");
- if (expiresHours > 0) {
- footer += "\n\n";
+ // When no branch name was provided by the agent, generate a unique one.
+ if (!branchName) {
+ core.info("No branch name provided in JSONL, generating unique branch name");
+ branchName = `${workflowId}-${randomHex}`;
}
- bodyLines.push(``, ``, footer);
- footerParts.push(footer);
- }
- // Add standalone workflow-id marker for searchability (consistent with comments)
- // Always add XML markers even when footer is disabled
- if (workflowId) {
- const workflowIdMarker = generateWorkflowIdMarker(workflowId);
- // Add to bodyLines for the normal PR body path.
- // Add to footerParts so the fallback issue body places it after the protected-files section.
- bodyLines.push(``, workflowIdMarker);
- footerParts.push(workflowIdMarker);
- }
+ // Apply the configured branch prefix (e.g. "signed/") if it hasn't already been applied.
+ if (branchPrefix && !branchName.startsWith(branchPrefix)) {
+ branchName = `${branchPrefix}${branchName}`;
+ core.info(`Applied branch prefix: ${branchName}`);
+ }
- // Embed gh-aw-workflow-call-id marker so callers sharing the same reusable workflow
- // do not close each other's PRs when close-older-pull-requests is enabled.
- if (callerWorkflowId) {
- bodyLines.push(generateWorkflowCallIdMarker(callerWorkflowId));
- }
+ core.info(`Generated branch name: ${branchName}`);
+ core.info(`Base branch: ${baseBranch}`);
- // Embed gh-aw-close-key marker when an explicit deduplication key is set.
- if (closeOlderKey) {
- bodyLines.push(generateCloseKeyMarker(closeOlderKey));
- }
+ // Create a new branch using git CLI, ensuring it's based on the correct base branch
- bodyLines.push("");
+ // First, fetch the base branch specifically (since we use shallow checkout)
+ core.info(`Fetching base branch: ${baseBranch}`);
- // Prepare the body content
- const body = bodyLines.join("\n").trim();
- const issueSafeBody = neutralizeClosingKeywordsForIssueBody(body);
- // Footer section (footer + workflow-id marker) used when ordering protected-files notices
- const footerContent = footerParts.join("\n\n");
+ // Fetch without creating/updating local branch to avoid conflicts with current branch
+ // This works even when we're already on the base branch
+ await exec.exec(`git fetch origin ${baseBranch}`);
- // Build labels array - merge config labels with message labels
- let labels = [...envLabels];
- if (pullRequestItem.labels && Array.isArray(pullRequestItem.labels)) {
- labels = [...labels, ...pullRequestItem.labels];
- }
- labels = labels
- .filter(label => !!label)
- .map(label => String(label).trim())
- .filter(label => label);
- // Add agentic-threat-detected label when threat detection produced a warning
- if (detectionCaution && !labels.includes("agentic-threat-detected")) {
- labels.push("agentic-threat-detected");
- }
- // Use explicitly configured fallback labels when present; otherwise preserve
- // existing behavior by reusing pull request labels for fallback issues.
- const effectiveFallbackLabels = configFallbackLabels.length > 0 ? configFallbackLabels : labels;
-
- // Configuration enforces draft as a policy, not a fallback (consistent with autoMerge/allowEmpty)
- const draft = draftDefault;
- if (pullRequestItem.draft !== undefined && pullRequestItem.draft !== draftDefault) {
- core.warning(
- `Agent requested draft: ${pullRequestItem.draft}, but configuration enforces draft: ${draftDefault}. ` +
- `Configuration takes precedence for security. To change this, update safe-outputs.create-pull-request.draft in the workflow file.`
- );
- }
+ // Apply the patch/bundle using git CLI (skip if empty)
+ // Track number of new commits pushed so we can restrict the extra empty commit
+ // to branches with exactly one new commit (security: prevents use of CI trigger
+ // token on multi-commit branches where workflow files may have been modified).
+ let newCommitCount = 0;
+ if (hasBundleFile) {
+ // Bundle transport: fetch commits directly from the bundle file.
+ // This preserves merge commit topology and per-commit metadata (messages, authorship)
+ // unlike git format-patch which flattens history and drops merge resolution content.
+ core.info(`Applying changes from bundle: ${bundleFilePath}`);
+ try {
+ await applyBundleToBranch(bundleFilePath, branchName, originalAgentBranch, exec);
+ } catch (bundleError) {
+ core.error(`Failed to apply bundle: ${bundleError instanceof Error ? bundleError.message : String(bundleError)}`);
+ return { success: false, error: "Failed to apply bundle" };
+ }
- core.info(`Creating pull request with title: ${title}`);
- core.info(`Labels: ${JSON.stringify(labels)}`);
- core.info(`Draft: ${draft}`);
- core.info(`Body length: ${body.length}`);
+ // Push the commits from the bundle to the remote branch
+ // Note: when manifestProtectionFallback is set we still push the branch so the
+ // fallback issue can include a compare URL. Genuine push failures are handled in
+ // the catch block below.
+ {
+ try {
+ branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient, owner: repoParts.owner, repo: repoParts.repo });
- // When no branch name was provided by the agent, generate a unique one.
- if (!branchName) {
- core.info("No branch name provided in JSONL, generating unique branch name");
- branchName = `${workflowId}-${randomHex}`;
- }
+ await pushSignedCommits({
+ githubClient,
+ owner: repoParts.owner,
+ repo: repoParts.repo,
+ branch: branchName,
+ baseRef: `origin/${baseBranch}`,
+ cwd: process.cwd(),
+ signedCommits,
+ resolvedTemporaryIds,
+ currentRepo: itemRepo,
+ });
+ core.info("Changes pushed to branch (from bundle)");
- // Apply the configured branch prefix (e.g. "signed/") if it hasn't already been applied.
- if (branchPrefix && !branchName.startsWith(branchPrefix)) {
- branchName = `${branchPrefix}${branchName}`;
- core.info(`Applied branch prefix: ${branchName}`);
- }
+ // Count new commits on PR branch relative to base
+ try {
+ const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]);
+ newCommitCount = parseInt(countStr.trim(), 10);
+ core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`);
+ } catch {
+ core.info("Could not count new commits - extra empty commit will be skipped");
+ }
+ } catch (initialPushError) {
+ /** @type {unknown} */
+ let pushError = initialPushError;
+ let pushRecovered = false;
+ const pushErrorMessage = pushError instanceof Error ? pushError.message : String(pushError);
+ const isSignedMergeReplayRefusal = signedCommits && /pushSignedCommits: refusing unsigned push/.test(pushErrorMessage) && /merge commit/i.test(pushErrorMessage);
+
+ if (isSignedMergeReplayRefusal) {
+ core.warning("Signed push rejected merge commit topology from bundle; rewriting branch and retrying signed push");
+ try {
+ await rewriteBundleBranchAsSingleCommit(baseBranch, exec);
+ await pushSignedCommits({
+ githubClient,
+ owner: repoParts.owner,
+ repo: repoParts.repo,
+ branch: branchName,
+ baseRef: `origin/${baseBranch}`,
+ cwd: process.cwd(),
+ signedCommits,
+ resolvedTemporaryIds,
+ currentRepo: itemRepo,
+ });
+ core.info("Changes pushed to branch after bundle rewrite retry");
- core.info(`Generated branch name: ${branchName}`);
- core.info(`Base branch: ${baseBranch}`);
+ try {
+ const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]);
+ newCommitCount = parseInt(countStr.trim(), 10);
+ core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`);
+ } catch {
+ core.info("Could not count new commits - extra empty commit will be skipped");
+ }
+ pushRecovered = true;
+ } catch (retryPushError) {
+ pushError = retryPushError;
+ }
+ }
- // Create a new branch using git CLI, ensuring it's based on the correct base branch
+ if (!pushRecovered) {
+ core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`);
+
+ if (manifestProtectionFallback) {
+ // Push failed specifically for a protected-file modification. Don't create
+ // a generic push-failed issue — fall through to the manifestProtectionFallback
+ // block below, which will create the proper protected-file review issue with
+ // patch artifact download instructions (since the branch was not pushed).
+ core.warning("Git push failed for protected-file modification - deferring to protected-file review issue");
+ manifestProtectionPushFailedError = pushError;
+ } else if (!fallbackAsIssue) {
+ const error = `Failed to push changes: ${pushError instanceof Error ? pushError.message : String(pushError)}`;
+ return { success: false, error, error_type: "push_failed" };
+ } else {
+ core.warning("Git push operation failed - creating fallback issue instead of pull request");
- // First, fetch the base branch specifically (since we use shallow checkout)
- core.info(`Fetching base branch: ${baseBranch}`);
+ const runUrl = buildWorkflowRunUrl(context, context.repo);
+ const runId = context.runId;
- // Fetch without creating/updating local branch to avoid conflicts with current branch
- // This works even when we're already on the base branch
- await exec.exec(`git fetch origin ${baseBranch}`);
+ const artifactFileName = bundleFilePath ? bundleFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.bundle";
+ const fallbackBundleSourceRef = `refs/heads/${originalAgentBranch || branchName}`;
+ const fallbackBundleTempRef = createBundleTempRef(branchName);
+ const fallbackBody = `${issueSafeBody}
- // Apply the patch/bundle using git CLI (skip if empty)
- // Track number of new commits pushed so we can restrict the extra empty commit
- // to branches with exactly one new commit (security: prevents use of CI trigger
- // token on multi-commit branches where workflow files may have been modified).
- let newCommitCount = 0;
- if (hasBundleFile) {
- // Bundle transport: fetch commits directly from the bundle file.
- // This preserves merge commit topology and per-commit metadata (messages, authorship)
- // unlike git format-patch which flattens history and drops merge resolution content.
- core.info(`Applying changes from bundle: ${bundleFilePath}`);
- try {
- await applyBundleToBranch(bundleFilePath, branchName, originalAgentBranch, exec);
- } catch (bundleError) {
- core.error(`Failed to apply bundle: ${bundleError instanceof Error ? bundleError.message : String(bundleError)}`);
- return { success: false, error: "Failed to apply bundle" };
- }
+---
- // Push the commits from the bundle to the remote branch
- // Note: when manifestProtectionFallback is set we still push the branch so the
- // fallback issue can include a compare URL. Genuine push failures are handled in
- // the catch block below.
- {
- try {
- branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient, owner: repoParts.owner, repo: repoParts.repo });
+> [!NOTE]
+> This was originally intended as a pull request, but the git push operation failed.
+>
+> **Workflow Run:** [View run details and download bundle artifact](${runUrl})
+>
+> The bundle file is available in the \`agent\` artifact in the workflow run linked above.
- await pushSignedCommits({
- githubClient,
- owner: repoParts.owner,
- repo: repoParts.repo,
- branch: branchName,
- baseRef: `origin/${baseBranch}`,
- cwd: process.cwd(),
- signedCommits,
- resolvedTemporaryIds,
- currentRepo: itemRepo,
- });
- core.info("Changes pushed to branch (from bundle)");
-
- // Count new commits on PR branch relative to base
- try {
- const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]);
- newCommitCount = parseInt(countStr.trim(), 10);
- core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`);
- } catch {
- core.info("Could not count new commits - extra empty commit will be skipped");
- }
- } catch (initialPushError) {
- /** @type {unknown} */
- let pushError = initialPushError;
- let pushRecovered = false;
- const pushErrorMessage = pushError instanceof Error ? pushError.message : String(pushError);
- const isSignedMergeReplayRefusal = signedCommits && /pushSignedCommits: refusing unsigned push/.test(pushErrorMessage) && /merge commit/i.test(pushErrorMessage);
-
- if (isSignedMergeReplayRefusal) {
- core.warning("Signed push rejected merge commit topology from bundle; rewriting branch and retrying signed push");
- try {
- await rewriteBundleBranchAsSingleCommit(baseBranch, exec);
- await pushSignedCommits({
- githubClient,
- owner: repoParts.owner,
- repo: repoParts.repo,
- branch: branchName,
- baseRef: `origin/${baseBranch}`,
- cwd: process.cwd(),
- signedCommits,
- resolvedTemporaryIds,
- currentRepo: itemRepo,
- });
- core.info("Changes pushed to branch after bundle rewrite retry");
-
- try {
- const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]);
- newCommitCount = parseInt(countStr.trim(), 10);
- core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`);
- } catch {
- core.info("Could not count new commits - extra empty commit will be skipped");
- }
- pushRecovered = true;
- } catch (retryPushError) {
- pushError = retryPushError;
- }
- }
-
- if (!pushRecovered) {
- core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`);
-
- if (manifestProtectionFallback) {
- // Push failed specifically for a protected-file modification. Don't create
- // a generic push-failed issue — fall through to the manifestProtectionFallback
- // block below, which will create the proper protected-file review issue with
- // patch artifact download instructions (since the branch was not pushed).
- core.warning("Git push failed for protected-file modification - deferring to protected-file review issue");
- manifestProtectionPushFailedError = pushError;
- } else if (!fallbackAsIssue) {
- const error = `Failed to push changes: ${pushError instanceof Error ? pushError.message : String(pushError)}`;
- return { success: false, error, error_type: "push_failed" };
- } else {
- core.warning("Git push operation failed - creating fallback issue instead of pull request");
-
- const runUrl = buildWorkflowRunUrl(context, context.repo);
- const runId = context.runId;
-
- const artifactFileName = bundleFilePath ? bundleFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.bundle";
- const fallbackBundleSourceRef = `refs/heads/${originalAgentBranch || branchName}`;
- const fallbackBundleTempRef = createBundleTempRef(branchName);
- const fallbackBody = `${issueSafeBody}
-
----
-
-> [!NOTE]
-> This was originally intended as a pull request, but the git push operation failed.
->
-> **Workflow Run:** [View run details and download bundle artifact](${runUrl})
->
-> The bundle file is available in the \`agent\` artifact in the workflow run linked above.
-
-To create a pull request with the changes:
+To create a pull request with the changes:
\`\`\`sh
# Download the artifact from the workflow run
@@ -1543,269 +1605,269 @@ git push origin ${branchName}
gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo ${repoParts.owner}/${repoParts.repo}
\`\`\``;
- try {
- const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees);
-
- core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);
- await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number);
- await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue");
-
- return {
- success: true,
- fallback_used: true,
- issue_number: issue.number,
- issue_url: issue.html_url,
- };
- } catch (issueError) {
- const error = `Failed to push changes and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`;
- return { success: false, error };
+ try {
+ const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees);
+
+ core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);
+ await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number);
+ await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue");
+
+ return {
+ success: true,
+ fallback_used: true,
+ issue_number: issue.number,
+ issue_url: issue.html_url,
+ };
+ } catch (issueError) {
+ const error = `Failed to push changes and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`;
+ return { success: false, error };
+ }
}
}
}
}
- }
- } else {
- // Checkout the base branch (using origin/${baseBranch} if local doesn't exist)
- try {
- await exec.exec(`git checkout ${baseBranch}`);
- } catch (checkoutError) {
- // If local branch doesn't exist, create it from origin
- core.info(`Local branch ${baseBranch} doesn't exist, creating from origin/${baseBranch}`);
- await exec.exec(`git checkout -b ${baseBranch} origin/${baseBranch}`);
- }
-
- // Handle branch creation/checkout
- let branchBaseRef = baseBranch;
- const recordedBaseCommit = normalizeCommitSHA(pullRequestItem.base_commit);
- if (recordedBaseCommit) {
- core.info(`Patch route base_commit resolved: ${recordedBaseCommit}`);
- core.info(`Using base_commit from safe output entry for patch apply: ${recordedBaseCommit}`);
+ } else {
+ // Checkout the base branch (using origin/${baseBranch} if local doesn't exist)
try {
- try {
- await exec.exec("git", ["fetch", "origin", recordedBaseCommit, "--depth=1"]);
- } catch (fetchError) {
- core.info(`Note: could not fetch base commit ${recordedBaseCommit} explicitly (${fetchError instanceof Error ? fetchError.message : String(fetchError)}); will verify local availability next`);
- }
- await exec.exec("git", ["cat-file", "-e", recordedBaseCommit]);
- const ancestryCheck = await exec.getExecOutput("git", ["merge-base", "--is-ancestor", recordedBaseCommit, `origin/${baseBranch}`], { ignoreReturnCode: true });
- if (ancestryCheck.exitCode !== 0) {
- throw new Error(`recorded base_commit ${recordedBaseCommit} is not an ancestor of origin/${baseBranch}; falling back to ${baseBranch}`);
- }
- branchBaseRef = recordedBaseCommit;
- } catch (baseCommitError) {
- core.warning(`Recorded base_commit ${recordedBaseCommit} is not available in this checkout (${baseCommitError instanceof Error ? baseCommitError.message : String(baseCommitError)}); falling back to ${baseBranch}`);
+ await exec.exec(`git checkout ${baseBranch}`);
+ } catch (checkoutError) {
+ // If local branch doesn't exist, create it from origin
+ core.info(`Local branch ${baseBranch} doesn't exist, creating from origin/${baseBranch}`);
+ await exec.exec(`git checkout -b ${baseBranch} origin/${baseBranch}`);
}
- } else if (String(pullRequestItem.base_commit ?? "").trim()) {
- core.warning(`Ignoring invalid base_commit value for patch apply: ${String(pullRequestItem.base_commit).trim()}`);
- }
- core.info(`Branch should not exist locally, creating new branch from base: ${branchName} (${branchBaseRef})`);
- await exec.exec("git", ["checkout", "-b", branchName, branchBaseRef]);
- core.info(`Created new branch from base: ${branchName} (${branchBaseRef})`);
- // Apply the patch using git CLI (skip if empty)
- if (!isEmpty) {
- // Resolve temporary ID references in patch content before applying
- // This handles references like #aw_XXX in committed source code
- if (resolvedTemporaryIds && Object.keys(resolvedTemporaryIds).length > 0) {
- const tempIdMap = new Map(Object.entries(resolvedTemporaryIds));
- const originalPatchContent = patchContent;
- patchContent = replaceTemporaryIdReferencesInPatch(patchContent, tempIdMap, itemRepo);
- if (patchContent !== originalPatchContent) {
- core.info("Resolved temporary ID references in patch content");
- fs.writeFileSync(patchFilePath, patchContent, "utf8");
+ // Handle branch creation/checkout
+ let branchBaseRef = baseBranch;
+ const recordedBaseCommit = normalizeCommitSHA(pullRequestItem.base_commit);
+ if (recordedBaseCommit) {
+ core.info(`Patch route base_commit resolved: ${recordedBaseCommit}`);
+ core.info(`Using base_commit from safe output entry for patch apply: ${recordedBaseCommit}`);
+ try {
+ try {
+ await exec.exec("git", ["fetch", "origin", recordedBaseCommit, "--depth=1"]);
+ } catch (fetchError) {
+ core.info(`Note: could not fetch base commit ${recordedBaseCommit} explicitly (${fetchError instanceof Error ? fetchError.message : String(fetchError)}); will verify local availability next`);
+ }
+ await exec.exec("git", ["cat-file", "-e", recordedBaseCommit]);
+ const ancestryCheck = await exec.getExecOutput("git", ["merge-base", "--is-ancestor", recordedBaseCommit, `origin/${baseBranch}`], { ignoreReturnCode: true });
+ if (ancestryCheck.exitCode !== 0) {
+ throw new Error(`recorded base_commit ${recordedBaseCommit} is not an ancestor of origin/${baseBranch}; falling back to ${baseBranch}`);
+ }
+ branchBaseRef = recordedBaseCommit;
+ } catch (baseCommitError) {
+ core.warning(`Recorded base_commit ${recordedBaseCommit} is not available in this checkout (${baseCommitError instanceof Error ? baseCommitError.message : String(baseCommitError)}); falling back to ${baseBranch}`);
}
+ } else if (String(pullRequestItem.base_commit ?? "").trim()) {
+ core.warning(`Ignoring invalid base_commit value for patch apply: ${String(pullRequestItem.base_commit).trim()}`);
}
+ core.info(`Branch should not exist locally, creating new branch from base: ${branchName} (${branchBaseRef})`);
+ await exec.exec("git", ["checkout", "-b", branchName, branchBaseRef]);
+ core.info(`Created new branch from base: ${branchName} (${branchBaseRef})`);
+
+ // Apply the patch using git CLI (skip if empty)
+ if (!isEmpty) {
+ // Resolve temporary ID references in patch content before applying
+ // This handles references like #aw_XXX in committed source code
+ if (resolvedTemporaryIds && Object.keys(resolvedTemporaryIds).length > 0) {
+ const tempIdMap = new Map(Object.entries(resolvedTemporaryIds));
+ const originalPatchContent = patchContent;
+ patchContent = replaceTemporaryIdReferencesInPatch(patchContent, tempIdMap, itemRepo);
+ if (patchContent !== originalPatchContent) {
+ core.info("Resolved temporary ID references in patch content");
+ fs.writeFileSync(patchFilePath, patchContent, "utf8");
+ }
+ }
- core.info("Applying patch...");
- const patchLines = patchContent.split("\n");
- const previewLineCount = Math.min(500, patchLines.length);
- core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`);
- for (let i = 0; i < previewLineCount; i++) {
- core.info(patchLines[i]);
- }
+ core.info("Applying patch...");
+ const patchLines = patchContent.split("\n");
+ const previewLineCount = Math.min(500, patchLines.length);
+ core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`);
+ for (let i = 0; i < previewLineCount; i++) {
+ core.info(patchLines[i]);
+ }
- // Patches are created with git format-patch, so use git am to apply them
- // Use --3way to handle cross-repo patches where the patch base may differ from target repo
- // This allows git to resolve create-vs-modify mismatches when a file exists in target but not source
- let patchApplied = false;
- try {
- await exec.exec("git", ["am", "--3way", patchFilePath]);
- core.info("Patch applied successfully");
- patchApplied = true;
- } catch (patchError) {
- core.error(`Failed to apply patch with --3way: ${patchError instanceof Error ? patchError.message : String(patchError)}`);
-
- const recoveredFromAddAddConflict = await tryRecoverGitAmAddAddConflict(exec);
- if (recoveredFromAddAddConflict.recovered) {
+ // Patches are created with git format-patch, so use git am to apply them
+ // Use --3way to handle cross-repo patches where the patch base may differ from target repo
+ // This allows git to resolve create-vs-modify mismatches when a file exists in target but not source
+ let patchApplied = false;
+ try {
+ await exec.exec("git", ["am", "--3way", patchFilePath]);
+ core.info("Patch applied successfully");
patchApplied = true;
- } else {
- if (recoveredFromAddAddConflict.errorMessage) {
- core.warning(`Automatic add/add conflict recovery attempt failed: ${recoveredFromAddAddConflict.errorMessage}`);
- }
- // Investigate why the patch failed by logging git status and the failed patch
- try {
- core.info("Investigating patch failure...");
-
- // Log git status to see the current state
- const statusResult = await exec.getExecOutput("git", ["status"]);
- core.info("Git status output:");
- core.info(statusResult.stdout);
-
- // Log the failed patch diff
- const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]);
- core.info("Failed patch content:");
- core.info(patchResult.stdout);
- } catch (investigateError) {
- core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`);
- }
+ } catch (patchError) {
+ core.error(`Failed to apply patch with --3way: ${patchError instanceof Error ? patchError.message : String(patchError)}`);
- // Abort the failed git am before attempting any fallback
- try {
- await exec.exec("git am --abort");
- core.info("Aborted failed git am");
- } catch (abortError) {
- core.warning(`Failed to abort git am: ${abortError instanceof Error ? abortError.message : String(abortError)}`);
- }
-
- // Fallback (Option 1): create the PR branch at the original base commit so the PR
- // can still be created. GitHub will show the merge conflicts, allowing manual resolution.
- // This handles the case where the target branch received intervening commits after
- // the patch was generated, making --3way unable to resolve the conflicts automatically.
- core.info("Attempting fallback: create PR branch at original base commit...");
- try {
- // Use the base commit recorded at patch generation time.
- // The From header in format-patch output contains the agent's new commit SHA
- // which does not exist in this checkout, so we cannot derive the base from it.
- const originalBaseCommit = normalizeCommitSHA(pullRequestItem.base_commit);
- if (!originalBaseCommit) {
- core.warning("No base_commit recorded in safe output entry - fallback not possible");
- } else {
- core.info(`Original base commit from patch generation: ${originalBaseCommit}`);
+ const recoveredFromAddAddConflict = await tryRecoverGitAmAddAddConflict(exec);
+ if (recoveredFromAddAddConflict.recovered) {
+ patchApplied = true;
+ } else {
+ if (recoveredFromAddAddConflict.errorMessage) {
+ core.warning(`Automatic add/add conflict recovery attempt failed: ${recoveredFromAddAddConflict.errorMessage}`);
+ }
+ // Investigate why the patch failed by logging git status and the failed patch
+ try {
+ core.info("Investigating patch failure...");
+
+ // Log git status to see the current state
+ const statusResult = await exec.getExecOutput("git", ["status"]);
+ core.info("Git status output:");
+ core.info(statusResult.stdout);
+
+ // Log the failed patch diff
+ const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]);
+ core.info("Failed patch content:");
+ core.info(patchResult.stdout);
+ } catch (investigateError) {
+ core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`);
+ }
- // In shallow clones (fetch-depth: 1) the base commit may not be locally available.
- // Attempt to fetch it explicitly before checking whether it exists.
- try {
- await exec.exec("git", ["fetch", "origin", originalBaseCommit, "--depth=1"]);
- } catch (fetchError) {
- // Non-fatal: the commit may already be available, or the server may not support
- // fetching individual SHAs (e.g. some GHE configurations). Log for troubleshooting.
- core.info(`Note: could not fetch base commit ${originalBaseCommit} explicitly (${fetchError instanceof Error ? fetchError.message : String(fetchError)}); will verify local availability next`);
- }
+ // Abort the failed git am before attempting any fallback
+ try {
+ await exec.exec("git am --abort");
+ core.info("Aborted failed git am");
+ } catch (abortError) {
+ core.warning(`Failed to abort git am: ${abortError instanceof Error ? abortError.message : String(abortError)}`);
+ }
- // Verify the base commit is available in this repo (may not exist cross-repo)
- await exec.exec("git", ["cat-file", "-e", originalBaseCommit]);
- core.info("Original base commit exists locally - proceeding with fallback");
+ // Fallback (Option 1): create the PR branch at the original base commit so the PR
+ // can still be created. GitHub will show the merge conflicts, allowing manual resolution.
+ // This handles the case where the target branch received intervening commits after
+ // the patch was generated, making --3way unable to resolve the conflicts automatically.
+ core.info("Attempting fallback: create PR branch at original base commit...");
+ try {
+ // Use the base commit recorded at patch generation time.
+ // The From header in format-patch output contains the agent's new commit SHA
+ // which does not exist in this checkout, so we cannot derive the base from it.
+ const originalBaseCommit = normalizeCommitSHA(pullRequestItem.base_commit);
+ if (!originalBaseCommit) {
+ core.warning("No base_commit recorded in safe output entry - fallback not possible");
+ } else {
+ core.info(`Original base commit from patch generation: ${originalBaseCommit}`);
+
+ // In shallow clones (fetch-depth: 1) the base commit may not be locally available.
+ // Attempt to fetch it explicitly before checking whether it exists.
+ try {
+ await exec.exec("git", ["fetch", "origin", originalBaseCommit, "--depth=1"]);
+ } catch (fetchError) {
+ // Non-fatal: the commit may already be available, or the server may not support
+ // fetching individual SHAs (e.g. some GHE configurations). Log for troubleshooting.
+ core.info(`Note: could not fetch base commit ${originalBaseCommit} explicitly (${fetchError instanceof Error ? fetchError.message : String(fetchError)}); will verify local availability next`);
+ }
- // Re-create the PR branch at the original base commit
- await exec.exec(`git checkout ${baseBranch}`);
- try {
- await exec.exec(`git branch -D ${branchName}`);
- } catch {
- // Branch may not exist yet, ignore
- }
- await exec.exec(`git checkout -b ${branchName} ${originalBaseCommit}`);
- core.info(`Created branch ${branchName} at original base commit ${originalBaseCommit}`);
+ // Verify the base commit is available in this repo (may not exist cross-repo)
+ await exec.exec("git", ["cat-file", "-e", originalBaseCommit]);
+ core.info("Original base commit exists locally - proceeding with fallback");
- // Try --3way first to maximize repair opportunities even on fallback branches.
- // If that still fails with add/add conflicts, recover and continue git am.
- try {
- await exec.exec("git", ["am", "--3way", patchFilePath]);
- } catch (fallbackPatchError) {
- core.warning(`Fallback git am --3way failed: ${getErrorMessage(fallbackPatchError)}`);
- const recoveredFallback = await tryRecoverGitAmAddAddConflict(exec);
- if (!recoveredFallback.recovered) {
- if (recoveredFallback.errorMessage) {
- core.warning(`Automatic add/add conflict recovery attempt failed during fallback: ${recoveredFallback.errorMessage}`);
- }
- try {
- await exec.exec("git am --abort");
- } catch (abortFallbackError) {
- core.warning(`Failed to abort fallback git am: ${getErrorMessage(abortFallbackError)}`);
+ // Re-create the PR branch at the original base commit
+ await exec.exec(`git checkout ${baseBranch}`);
+ try {
+ await exec.exec(`git branch -D ${branchName}`);
+ } catch {
+ // Branch may not exist yet, ignore
+ }
+ await exec.exec(`git checkout -b ${branchName} ${originalBaseCommit}`);
+ core.info(`Created branch ${branchName} at original base commit ${originalBaseCommit}`);
+
+ // Try --3way first to maximize repair opportunities even on fallback branches.
+ // If that still fails with add/add conflicts, recover and continue git am.
+ try {
+ await exec.exec("git", ["am", "--3way", patchFilePath]);
+ } catch (fallbackPatchError) {
+ core.warning(`Fallback git am --3way failed: ${getErrorMessage(fallbackPatchError)}`);
+ const recoveredFallback = await tryRecoverGitAmAddAddConflict(exec);
+ if (!recoveredFallback.recovered) {
+ if (recoveredFallback.errorMessage) {
+ core.warning(`Automatic add/add conflict recovery attempt failed during fallback: ${recoveredFallback.errorMessage}`);
+ }
+ try {
+ await exec.exec("git am --abort");
+ } catch (abortFallbackError) {
+ core.warning(`Failed to abort fallback git am: ${getErrorMessage(abortFallbackError)}`);
+ }
+ throw fallbackPatchError;
}
- throw fallbackPatchError;
}
- }
- core.info("Patch applied successfully at original base commit");
- core.warning(`PR branch ${branchName} is based on an earlier commit than the current ${baseBranch} HEAD. The pull request will show merge conflicts that require manual resolution.`);
- patchApplied = true;
+ core.info("Patch applied successfully at original base commit");
+ core.warning(`PR branch ${branchName} is based on an earlier commit than the current ${baseBranch} HEAD. The pull request will show merge conflicts that require manual resolution.`);
+ patchApplied = true;
+ }
+ } catch (fallbackError) {
+ core.warning(`Fallback to original base commit failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`);
}
- } catch (fallbackError) {
- core.warning(`Fallback to original base commit failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`);
}
- }
- if (!patchApplied) {
- return { success: false, error: "Failed to apply patch" };
+ if (!patchApplied) {
+ return { success: false, error: "Failed to apply patch" };
+ }
}
- }
- // Push the applied commits to the branch (with fallback to issue creation on failure)
- // Note: when manifestProtectionFallback is set we still push the branch so the
- // fallback issue can include a compare URL. Genuine push failures are handled in
- // the catch block below.
- {
- try {
- branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient, owner: repoParts.owner, repo: repoParts.repo });
-
- await pushSignedCommits({
- githubClient,
- owner: repoParts.owner,
- repo: repoParts.repo,
- branch: branchName,
- baseRef: `origin/${baseBranch}`,
- cwd: process.cwd(),
- signedCommits,
- resolvedTemporaryIds,
- currentRepo: itemRepo,
- });
- core.info("Changes pushed to branch");
-
- // Count new commits on PR branch relative to base, used to restrict
- // the extra empty CI-trigger commit to exactly 1 new commit.
+ // Push the applied commits to the branch (with fallback to issue creation on failure)
+ // Note: when manifestProtectionFallback is set we still push the branch so the
+ // fallback issue can include a compare URL. Genuine push failures are handled in
+ // the catch block below.
+ {
try {
- const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]);
- newCommitCount = parseInt(countStr.trim(), 10);
- core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`);
- } catch {
- // Non-fatal - newCommitCount stays 0, extra empty commit will be skipped
- core.info("Could not count new commits - extra empty commit will be skipped");
- }
- } catch (pushError) {
- // Push failed - create fallback issue instead of PR (if fallback is enabled)
- core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`);
-
- if (manifestProtectionFallback) {
- // Push failed specifically for a protected-file modification. Don't create
- // a generic push-failed issue — fall through to the manifestProtectionFallback
- // block below, which will create the proper protected-file review issue with
- // patch artifact download instructions (since the branch was not pushed).
- core.warning("Git push failed for protected-file modification - deferring to protected-file review issue");
- manifestProtectionPushFailedError = pushError;
- } else if (!fallbackAsIssue) {
- // Fallback is disabled - return error without creating issue
- core.error("fallback-as-issue is disabled - not creating fallback issue");
- const error = `Failed to push changes: ${pushError instanceof Error ? pushError.message : String(pushError)}`;
- return {
- success: false,
- error,
- error_type: "push_failed",
- };
- } else {
- core.warning("Git push operation failed - creating fallback issue instead of pull request");
+ branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient, owner: repoParts.owner, repo: repoParts.repo });
- const runUrl = buildWorkflowRunUrl(context, context.repo);
- const runId = context.runId;
+ await pushSignedCommits({
+ githubClient,
+ owner: repoParts.owner,
+ repo: repoParts.repo,
+ branch: branchName,
+ baseRef: `origin/${baseBranch}`,
+ cwd: process.cwd(),
+ signedCommits,
+ resolvedTemporaryIds,
+ currentRepo: itemRepo,
+ });
+ core.info("Changes pushed to branch");
- // Read patch content for preview
- let patchPreview = "";
- if (patchFilePath && fs.existsSync(patchFilePath)) {
- const patchContent = fs.readFileSync(patchFilePath, "utf8");
- patchPreview = generatePatchPreview(patchContent);
+ // Count new commits on PR branch relative to base, used to restrict
+ // the extra empty CI-trigger commit to exactly 1 new commit.
+ try {
+ const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]);
+ newCommitCount = parseInt(countStr.trim(), 10);
+ core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`);
+ } catch {
+ // Non-fatal - newCommitCount stays 0, extra empty commit will be skipped
+ core.info("Could not count new commits - extra empty commit will be skipped");
}
+ } catch (pushError) {
+ // Push failed - create fallback issue instead of PR (if fallback is enabled)
+ core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`);
+
+ if (manifestProtectionFallback) {
+ // Push failed specifically for a protected-file modification. Don't create
+ // a generic push-failed issue — fall through to the manifestProtectionFallback
+ // block below, which will create the proper protected-file review issue with
+ // patch artifact download instructions (since the branch was not pushed).
+ core.warning("Git push failed for protected-file modification - deferring to protected-file review issue");
+ manifestProtectionPushFailedError = pushError;
+ } else if (!fallbackAsIssue) {
+ // Fallback is disabled - return error without creating issue
+ core.error("fallback-as-issue is disabled - not creating fallback issue");
+ const error = `Failed to push changes: ${pushError instanceof Error ? pushError.message : String(pushError)}`;
+ return {
+ success: false,
+ error,
+ error_type: "push_failed",
+ };
+ } else {
+ core.warning("Git push operation failed - creating fallback issue instead of pull request");
- const patchFileName = patchFilePath ? patchFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.patch";
- const fallbackBody = `${issueSafeBody}
+ const runUrl = buildWorkflowRunUrl(context, context.repo);
+ const runId = context.runId;
+
+ // Read patch content for preview
+ let patchPreview = "";
+ if (patchFilePath && fs.existsSync(patchFilePath)) {
+ const patchContent = fs.readFileSync(patchFilePath, "utf8");
+ patchPreview = generatePatchPreview(patchContent);
+ }
+
+ const patchFileName = patchFilePath ? patchFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.patch";
+ const fallbackBody = `${issueSafeBody}
---
@@ -1836,22 +1898,22 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo
\`\`\`
${patchPreview}`;
- try {
- const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees);
+ try {
+ const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees);
- core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);
- await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number);
+ core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);
+ await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number);
- // Update the activation comment with issue link (if a comment was created)
- //
- // NOTE: we pass 'github' (global octokit) instead of githubClient (repo-scoped octokit) because the issue is created
- // in the same repo as the activation, so the global client has the correct context for updating the comment.
- await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue");
+ // Update the activation comment with issue link (if a comment was created)
+ //
+ // NOTE: we pass 'github' (global octokit) instead of githubClient (repo-scoped octokit) because the issue is created
+ // in the same repo as the activation, so the global client has the correct context for updating the comment.
+ await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue");
- // Write summary to GitHub Actions summary
- await core.summary
- .addRaw(
- `
+ // Write summary to GitHub Actions summary
+ await core.summary
+ .addRaw(
+ `
## Push Failure Fallback
- **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)}
@@ -1859,421 +1921,480 @@ ${patchPreview}`;
- **Patch Artifact:** Available in workflow run artifacts
- **Note:** Push failed, created issue as fallback
`
- )
- .write();
+ )
+ .write();
+
+ return {
+ success: true,
+ fallback_used: true,
+ push_failed: true,
+ issue_number: issue.number,
+ issue_url: issue.html_url,
+ branch_name: branchName,
+ repo: itemRepo,
+ };
+ } catch (issueError) {
+ const error = `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`;
+ core.error(error);
+ return {
+ success: false,
+ error,
+ };
+ }
+ } // end else (generic push-failed fallback)
+ }
+ }
+ } else {
+ core.info("Skipping patch application (empty patch)");
- return {
- success: true,
- fallback_used: true,
- push_failed: true,
- issue_number: issue.number,
- issue_url: issue.html_url,
- branch_name: branchName,
- repo: itemRepo,
- };
- } catch (issueError) {
- const error = `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`;
- core.error(error);
- return {
- success: false,
- error,
- };
+ // For empty patches with allow-empty, we still need to push the branch
+ if (allowEmpty) {
+ core.info("allow-empty is enabled - will create branch and push with empty commit");
+ // Push the branch with an empty commit to allow PR creation
+ try {
+ // Create an empty commit to ensure there's a commit difference
+ 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 });
+
+ await pushSignedCommits({
+ githubClient,
+ owner: repoParts.owner,
+ repo: repoParts.repo,
+ branch: branchName,
+ baseRef: `origin/${baseBranch}`,
+ cwd: process.cwd(),
+ signedCommits,
+ resolvedTemporaryIds,
+ currentRepo: itemRepo,
+ });
+ core.info("Empty branch pushed successfully");
+
+ // Count new commits (will be 1 from the Initialize commit)
+ try {
+ const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]);
+ newCommitCount = parseInt(countStr.trim(), 10);
+ core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`);
+ } catch {
+ // Non-fatal - newCommitCount stays 0, extra empty commit will be skipped
+ core.info("Could not count new commits - extra empty commit will be skipped");
}
- } // end else (generic push-failed fallback)
+ } catch (pushError) {
+ const error = `Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`;
+ core.error(error);
+ return {
+ success: false,
+ error,
+ error_type: "push_failed",
+ };
+ }
+ } else {
+ // For empty patches without allow-empty, handle if-no-changes configuration
+ const message = "No changes to apply - noop operation completed successfully";
+
+ switch (ifNoChanges) {
+ case "error":
+ return { success: false, error: "No changes to apply - failing as configured by if-no-changes: error" };
+
+ case "ignore":
+ // Silent success - no console output
+ return { success: false, skipped: true };
+
+ case "warn":
+ default:
+ core.warning(message);
+ return { success: false, error: message, skipped: true };
+ }
}
+ } // end if (!isEmpty) / else patch application block
+ } // end else (!hasBundleFile - patch path)
+
+ // Protected file protection – fallback-to-issue path:
+ // The patch has been applied (and pushed, unless manifestProtectionPushFailedError is set).
+ // Instead of creating a pull request, we create a review issue so a human can carefully
+ // inspect the protected file changes before merging.
+ // - Normal case (push succeeded): provides a GitHub compare URL to click and create the PR.
+ // - Push-failed case: push was rejected (e.g. missing `workflows` permission); provides
+ // patch artifact download instructions instead of the compare URL.
+ if (manifestProtectionFallback) {
+ const allFound = manifestProtectionFallback;
+ const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
+ // Use head branch (branchName) for links when push succeeded; fall back to baseBranch
+ // for the push-failed case where the head branch is not yet on the remote.
+ const branchForLinks = manifestProtectionPushFailedError ? baseBranch : branchName;
+ const fileList = buildProtectedFileList(allFound, githubServer, repoParts.owner, repoParts.repo, branchForLinks);
+
+ let fallbackBody;
+ if (manifestProtectionPushFailedError) {
+ // Push failed — branch not on remote, so compare URL is unavailable.
+ // Use the push-failed template with artifact download instructions.
+ const runId = context.runId;
+ const patchFileName = patchFilePath ? patchFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.patch";
+ const pushFailedTemplatePath = getPromptPath("manifest_protection_push_failed_fallback.md");
+ fallbackBody = renderTemplateFromFile(pushFailedTemplatePath, {
+ main_body: issueSafeMainBodyContent,
+ footer: footerContent,
+ files: fileList,
+ run_id: String(runId),
+ branch_name: branchName,
+ base_branch: baseBranch,
+ patch_file: patchFileName,
+ title,
+ repo: `${repoParts.owner}/${repoParts.repo}`,
+ });
+ } else {
+ // Normal case — push succeeded, provide compare URL.
+ const createPrUrl = buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, branchName, title);
+ fallbackBody = renderManifestProtectionFallbackBody(issueSafeMainBodyContent, footerContent, fileList, createPrUrl);
}
- } else {
- core.info("Skipping patch application (empty patch)");
- // For empty patches with allow-empty, we still need to push the branch
- if (allowEmpty) {
- core.info("allow-empty is enabled - will create branch and push with empty commit");
- // Push the branch with an empty commit to allow PR creation
- try {
- // Create an empty commit to ensure there's a commit difference
- 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 });
+ try {
+ const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees);
- await pushSignedCommits({
- githubClient,
- owner: repoParts.owner,
- repo: repoParts.repo,
- branch: branchName,
- baseRef: `origin/${baseBranch}`,
- cwd: process.cwd(),
- signedCommits,
- resolvedTemporaryIds,
- currentRepo: itemRepo,
- });
- core.info("Empty branch pushed successfully");
+ core.info(`Created protected-file-protection review issue #${issue.number}: ${issue.html_url}`);
- // Count new commits (will be 1 from the Initialize commit)
+ if (!manifestProtectionPushFailedError) {
try {
- const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]);
- newCommitCount = parseInt(countStr.trim(), 10);
- core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`);
- } catch {
- // Non-fatal - newCommitCount stays 0, extra empty commit will be skipped
- core.info("Could not count new commits - extra empty commit will be skipped");
+ const createPrUrl = buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, branchName, title, issue.number);
+ const fallbackBodyWithCloseKeyword = renderManifestProtectionFallbackBody(issueSafeMainBodyContent, footerContent, fileList, createPrUrl);
+
+ await withRetry(
+ () =>
+ githubClient.rest.issues.update({
+ owner: repoParts.owner,
+ repo: repoParts.repo,
+ issue_number: issue.number,
+ body: fallbackBodyWithCloseKeyword,
+ }),
+ RATE_LIMIT_RETRY_CONFIG,
+ `update protected-file-protection fallback issue #${issue.number} with auto-close link`
+ );
+ } catch (updateIssueBodyError) {
+ core.warning(`Failed to update protected-file-protection fallback issue #${issue.number} with auto-close link: ${updateIssueBodyError instanceof Error ? updateIssueBodyError.message : String(updateIssueBodyError)}`);
}
- } catch (pushError) {
- const error = `Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`;
- core.error(error);
- return {
- success: false,
- error,
- error_type: "push_failed",
- };
}
- } else {
- // For empty patches without allow-empty, handle if-no-changes configuration
- const message = "No changes to apply - noop operation completed successfully";
- switch (ifNoChanges) {
- case "error":
- return { success: false, error: "No changes to apply - failing as configured by if-no-changes: error" };
+ await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number);
- case "ignore":
- // Silent success - no console output
- return { success: false, skipped: true };
+ await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue");
- case "warn":
- default:
- core.warning(message);
- return { success: false, error: message, skipped: true };
- }
+ return {
+ success: true,
+ fallback_used: true,
+ issue_number: issue.number,
+ issue_url: issue.html_url,
+ branch_name: branchName,
+ repo: itemRepo,
+ };
+ } catch (issueError) {
+ const error = `Protected file protection: failed to create review issue. Error: ${issueError instanceof Error ? issueError.message : String(issueError)}`;
+ core.error(error);
+ return { success: false, error };
}
- } // end if (!isEmpty) / else patch application block
- } // end else (!hasBundleFile - patch path)
-
- // Protected file protection – fallback-to-issue path:
- // The patch has been applied (and pushed, unless manifestProtectionPushFailedError is set).
- // Instead of creating a pull request, we create a review issue so a human can carefully
- // inspect the protected file changes before merging.
- // - Normal case (push succeeded): provides a GitHub compare URL to click and create the PR.
- // - Push-failed case: push was rejected (e.g. missing `workflows` permission); provides
- // patch artifact download instructions instead of the compare URL.
- if (manifestProtectionFallback) {
- const allFound = manifestProtectionFallback;
- const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- // Use head branch (branchName) for links when push succeeded; fall back to baseBranch
- // for the push-failed case where the head branch is not yet on the remote.
- const branchForLinks = manifestProtectionPushFailedError ? baseBranch : branchName;
- const fileList = buildProtectedFileList(allFound, githubServer, repoParts.owner, repoParts.repo, branchForLinks);
-
- let fallbackBody;
- if (manifestProtectionPushFailedError) {
- // Push failed — branch not on remote, so compare URL is unavailable.
- // Use the push-failed template with artifact download instructions.
- const runId = context.runId;
- const patchFileName = patchFilePath ? patchFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.patch";
- const pushFailedTemplatePath = getPromptPath("manifest_protection_push_failed_fallback.md");
- fallbackBody = renderTemplateFromFile(pushFailedTemplatePath, {
- main_body: issueSafeMainBodyContent,
- footer: footerContent,
- files: fileList,
- run_id: String(runId),
- branch_name: branchName,
- base_branch: baseBranch,
- patch_file: patchFileName,
- title,
- repo: `${repoParts.owner}/${repoParts.repo}`,
- });
- } else {
- // Normal case — push succeeded, provide compare URL.
- const createPrUrl = buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, branchName, title);
- fallbackBody = renderManifestProtectionFallbackBody(issueSafeMainBodyContent, footerContent, fileList, createPrUrl);
}
+ // Try to create the pull request, with fallback to issue creation
try {
- const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees);
+ const { data: pullRequest } = await withRetry(
+ () =>
+ githubClient.rest.pulls.create({
+ owner: repoParts.owner,
+ repo: repoParts.repo,
+ title: title,
+ body: body,
+ head: branchName,
+ base: baseBranch,
+ draft: draft,
+ }),
+ RATE_LIMIT_RETRY_CONFIG,
+ `create pull request in ${repoParts.owner}/${repoParts.repo}`
+ );
- core.info(`Created protected-file-protection review issue #${issue.number}: ${issue.html_url}`);
+ core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`);
- if (!manifestProtectionPushFailedError) {
+ // Add labels if specified
+ if (labels.length > 0) {
try {
- const createPrUrl = buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch, branchName, title, issue.number);
- const fallbackBodyWithCloseKeyword = renderManifestProtectionFallbackBody(issueSafeMainBodyContent, footerContent, fileList, createPrUrl);
-
await withRetry(
() =>
- githubClient.rest.issues.update({
+ githubClient.rest.issues.addLabels({
owner: repoParts.owner,
repo: repoParts.repo,
- issue_number: issue.number,
- body: fallbackBodyWithCloseKeyword,
+ issue_number: pullRequest.number,
+ labels: labels,
}),
- RATE_LIMIT_RETRY_CONFIG,
- `update protected-file-protection fallback issue #${issue.number} with auto-close link`
+ {
+ maxRetries: LABEL_MAX_RETRIES,
+ initialDelayMs: LABEL_INITIAL_DELAY_MS,
+ maxDelayMs: LABEL_MAX_DELAY_MS,
+ backoffMultiplier: 2,
+ shouldRetry: isLabelTransientError,
+ },
+ `add labels to PR #${pullRequest.number}`
);
- } catch (updateIssueBodyError) {
- core.warning(`Failed to update protected-file-protection fallback issue #${issue.number} with auto-close link: ${updateIssueBodyError instanceof Error ? updateIssueBodyError.message : String(updateIssueBodyError)}`);
+ core.info(`Added labels to pull request: ${JSON.stringify(labels)}`);
+ } catch (labelError) {
+ // Label addition is non-critical - warn but don't fail the PR creation.
+ // GitHub's API may transiently fail to resolve the PR node ID immediately
+ // after creation, which causes label operations to fail with an unprocessable error.
+ // If this warning appears, repository checks that require labels on the opened event
+ // may fail transiently; consider triggering required-label checks on the labeled event instead.
+ core.warning(`Failed to add labels to PR #${pullRequest.number}: ${labelError instanceof Error ? labelError.message : String(labelError)}`);
}
}
- await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number);
-
- await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue");
+ // Add configured reviewers if specified
+ if (configReviewers.length > 0 || configTeamReviewers.length > 0) {
+ const hasCopilot = configReviewers.includes("copilot");
+ const otherReviewers = configReviewers.filter(r => r !== "copilot");
- return {
- success: true,
- fallback_used: true,
- issue_number: issue.number,
- issue_url: issue.html_url,
- branch_name: branchName,
- repo: itemRepo,
- };
- } catch (issueError) {
- const error = `Protected file protection: failed to create review issue. Error: ${issueError instanceof Error ? issueError.message : String(issueError)}`;
- core.error(error);
- return { success: false, error };
- }
- }
-
- // Try to create the pull request, with fallback to issue creation
- try {
- const { data: pullRequest } = await withRetry(
- () =>
- githubClient.rest.pulls.create({
- owner: repoParts.owner,
- repo: repoParts.repo,
- title: title,
- body: body,
- head: branchName,
- base: baseBranch,
- draft: draft,
- }),
- RATE_LIMIT_RETRY_CONFIG,
- `create pull request in ${repoParts.owner}/${repoParts.repo}`
- );
-
- core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`);
-
- // Add labels if specified
- if (labels.length > 0) {
- try {
- await withRetry(
- () =>
- githubClient.rest.issues.addLabels({
+ if (otherReviewers.length > 0 || configTeamReviewers.length > 0) {
+ core.info(`Requesting reviewers for pull request #${pullRequest.number}: reviewers=${JSON.stringify(otherReviewers)}, team_reviewers=${JSON.stringify(configTeamReviewers)}`);
+ try {
+ /** @type {{ owner: string, repo: string, pull_number: number, reviewers: string[], team_reviewers?: string[] }} */
+ const reviewerRequest = {
owner: repoParts.owner,
repo: repoParts.repo,
- issue_number: pullRequest.number,
- labels: labels,
- }),
- {
- maxRetries: LABEL_MAX_RETRIES,
- initialDelayMs: LABEL_INITIAL_DELAY_MS,
- maxDelayMs: LABEL_MAX_DELAY_MS,
- backoffMultiplier: 2,
- shouldRetry: isLabelTransientError,
- },
- `add labels to PR #${pullRequest.number}`
- );
- core.info(`Added labels to pull request: ${JSON.stringify(labels)}`);
- } catch (labelError) {
- // Label addition is non-critical - warn but don't fail the PR creation.
- // GitHub's API may transiently fail to resolve the PR node ID immediately
- // after creation, which causes label operations to fail with an unprocessable error.
- // If this warning appears, repository checks that require labels on the opened event
- // may fail transiently; consider triggering required-label checks on the labeled event instead.
- core.warning(`Failed to add labels to PR #${pullRequest.number}: ${labelError instanceof Error ? labelError.message : String(labelError)}`);
- }
- }
-
- // Add configured reviewers if specified
- if (configReviewers.length > 0 || configTeamReviewers.length > 0) {
- const hasCopilot = configReviewers.includes("copilot");
- const otherReviewers = configReviewers.filter(r => r !== "copilot");
-
- if (otherReviewers.length > 0 || configTeamReviewers.length > 0) {
- core.info(`Requesting reviewers for pull request #${pullRequest.number}: reviewers=${JSON.stringify(otherReviewers)}, team_reviewers=${JSON.stringify(configTeamReviewers)}`);
- try {
- /** @type {{ owner: string, repo: string, pull_number: number, reviewers: string[], team_reviewers?: string[] }} */
- const reviewerRequest = {
- owner: repoParts.owner,
- repo: repoParts.repo,
- pull_number: pullRequest.number,
- reviewers: otherReviewers,
- };
- if (configTeamReviewers.length > 0) {
- reviewerRequest.team_reviewers = configTeamReviewers;
+ pull_number: pullRequest.number,
+ reviewers: otherReviewers,
+ };
+ if (configTeamReviewers.length > 0) {
+ reviewerRequest.team_reviewers = configTeamReviewers;
+ }
+ await githubClient.rest.pulls.requestReviewers(reviewerRequest);
+ core.info(`Requested reviewers for pull request #${pullRequest.number}: reviewers=${JSON.stringify(otherReviewers)}, team_reviewers=${JSON.stringify(configTeamReviewers)}`);
+ } catch (reviewerError) {
+ core.warning(`Failed to request reviewers for PR #${pullRequest.number}: ${reviewerError instanceof Error ? reviewerError.message : String(reviewerError)}`);
}
- await githubClient.rest.pulls.requestReviewers(reviewerRequest);
- core.info(`Requested reviewers for pull request #${pullRequest.number}: reviewers=${JSON.stringify(otherReviewers)}, team_reviewers=${JSON.stringify(configTeamReviewers)}`);
- } catch (reviewerError) {
- core.warning(`Failed to request reviewers for PR #${pullRequest.number}: ${reviewerError instanceof Error ? reviewerError.message : String(reviewerError)}`);
}
- }
- if (hasCopilot) {
- core.info(`Requesting copilot as reviewer for pull request #${pullRequest.number}`);
- try {
- await githubClient.rest.pulls.requestReviewers({
- owner: repoParts.owner,
- repo: repoParts.repo,
- pull_number: pullRequest.number,
- reviewers: [COPILOT_REVIEWER_BOT],
- });
- core.info(`Requested copilot as reviewer for pull request #${pullRequest.number}`);
- } catch (copilotError) {
- core.warning(`Failed to request copilot as reviewer for PR #${pullRequest.number}: ${copilotError instanceof Error ? copilotError.message : String(copilotError)}`);
+ if (hasCopilot) {
+ core.info(`Requesting copilot as reviewer for pull request #${pullRequest.number}`);
+ try {
+ await githubClient.rest.pulls.requestReviewers({
+ owner: repoParts.owner,
+ repo: repoParts.repo,
+ pull_number: pullRequest.number,
+ reviewers: [COPILOT_REVIEWER_BOT],
+ });
+ core.info(`Requested copilot as reviewer for pull request #${pullRequest.number}`);
+ } catch (copilotError) {
+ core.warning(`Failed to request copilot as reviewer for PR #${pullRequest.number}: ${copilotError instanceof Error ? copilotError.message : String(copilotError)}`);
+ }
}
}
- }
- const requestChangesSections = [];
- if (manifestProtectionRequestReview && manifestProtectionRequestReview.length > 0) {
- const protectedFilesReviewTemplatePath = getPromptPath("manifest_protection_request_changes_review.md");
- requestChangesSections.push(
- renderTemplateFromFile(protectedFilesReviewTemplatePath, {
- files: renderFilesList(manifestProtectionRequestReview),
- })
- );
- }
- if (detectionCaution) {
- const detectionReason = process.env.GH_AW_DETECTION_REASON || "unknown";
- const detectionWarningReviewTemplatePath = getPromptPath("threat_warning_request_changes_review.md");
- requestChangesSections.push(
- renderTemplateFromFile(detectionWarningReviewTemplatePath, {
- detectionReason,
- runUrl,
- })
- );
- }
- if (requestChangesSections.length > 0) {
- const requestChangesBody = requestChangesSections.join("\n\n---\n\n");
- /** @type {{ owner: string, repo: string, pull_number: number, event: "REQUEST_CHANGES" | "COMMENT", body: string, commit_id?: string }} */
- const requestChangesParams = {
- owner: repoParts.owner,
- repo: repoParts.repo,
- pull_number: pullRequest.number,
- event: "REQUEST_CHANGES",
- body: requestChangesBody,
- };
- if (pullRequest.head && pullRequest.head.sha) {
- requestChangesParams.commit_id = pullRequest.head.sha;
+ const requestChangesSections = [];
+ if (manifestProtectionRequestReview && manifestProtectionRequestReview.length > 0) {
+ const protectedFilesReviewTemplatePath = getPromptPath("manifest_protection_request_changes_review.md");
+ requestChangesSections.push(
+ renderTemplateFromFile(protectedFilesReviewTemplatePath, {
+ files: renderFilesList(manifestProtectionRequestReview),
+ })
+ );
}
- core.info(`Creating REQUEST_CHANGES review for PR #${pullRequest.number} due to protected files`);
- try {
- await withRetry(() => githubClient.rest.pulls.createReview(requestChangesParams), RATE_LIMIT_RETRY_CONFIG, `create REQUEST_CHANGES review for PR #${pullRequest.number}`);
- core.info(`Created REQUEST_CHANGES review for PR #${pullRequest.number}`);
- } catch (requestChangesError) {
- const requestChangesErrorMessage = getErrorMessage(requestChangesError);
- const ownPrMessages = ["Can not request changes on your own pull request"];
- if (ownPrMessages.some(msg => requestChangesErrorMessage.includes(msg))) {
- core.warning(`Cannot submit REQUEST_CHANGES on own PR #${pullRequest.number}. Retrying with COMMENT.`);
- try {
- const commentReviewParams = { ...requestChangesParams, event: "COMMENT" };
- await withRetry(() => githubClient.rest.pulls.createReview(commentReviewParams), RATE_LIMIT_RETRY_CONFIG, `create COMMENT review fallback for PR #${pullRequest.number}`);
- core.info(`Created COMMENT review fallback for PR #${pullRequest.number}`);
- } catch (commentReviewError) {
- core.warning(`Failed to create COMMENT review fallback for PR #${pullRequest.number}: ${commentReviewError instanceof Error ? commentReviewError.message : String(commentReviewError)}`);
+ if (detectionCaution) {
+ const detectionReason = process.env.GH_AW_DETECTION_REASON || "unknown";
+ const detectionWarningReviewTemplatePath = getPromptPath("threat_warning_request_changes_review.md");
+ requestChangesSections.push(
+ renderTemplateFromFile(detectionWarningReviewTemplatePath, {
+ detectionReason,
+ runUrl,
+ })
+ );
+ }
+ if (requestChangesSections.length > 0) {
+ const requestChangesBody = requestChangesSections.join("\n\n---\n\n");
+ /** @type {{ owner: string, repo: string, pull_number: number, event: "REQUEST_CHANGES" | "COMMENT", body: string, commit_id?: string }} */
+ const requestChangesParams = {
+ owner: repoParts.owner,
+ repo: repoParts.repo,
+ pull_number: pullRequest.number,
+ event: "REQUEST_CHANGES",
+ body: requestChangesBody,
+ };
+ if (pullRequest.head && pullRequest.head.sha) {
+ requestChangesParams.commit_id = pullRequest.head.sha;
+ }
+ core.info(`Creating REQUEST_CHANGES review for PR #${pullRequest.number} due to protected files`);
+ try {
+ await withRetry(() => githubClient.rest.pulls.createReview(requestChangesParams), RATE_LIMIT_RETRY_CONFIG, `create REQUEST_CHANGES review for PR #${pullRequest.number}`);
+ core.info(`Created REQUEST_CHANGES review for PR #${pullRequest.number}`);
+ } catch (requestChangesError) {
+ const requestChangesErrorMessage = getErrorMessage(requestChangesError);
+ const ownPrMessages = ["Can not request changes on your own pull request"];
+ if (ownPrMessages.some(msg => requestChangesErrorMessage.includes(msg))) {
+ core.warning(`Cannot submit REQUEST_CHANGES on own PR #${pullRequest.number}. Retrying with COMMENT.`);
+ try {
+ const commentReviewParams = { ...requestChangesParams, event: "COMMENT" };
+ await withRetry(() => githubClient.rest.pulls.createReview(commentReviewParams), RATE_LIMIT_RETRY_CONFIG, `create COMMENT review fallback for PR #${pullRequest.number}`);
+ core.info(`Created COMMENT review fallback for PR #${pullRequest.number}`);
+ } catch (commentReviewError) {
+ core.warning(`Failed to create COMMENT review fallback for PR #${pullRequest.number}: ${commentReviewError instanceof Error ? commentReviewError.message : String(commentReviewError)}`);
+ }
+ } else {
+ core.warning(`Failed to create REQUEST_CHANGES review for PR #${pullRequest.number}: ${requestChangesErrorMessage}`);
}
- } else {
- core.warning(`Failed to create REQUEST_CHANGES review for PR #${pullRequest.number}: ${requestChangesErrorMessage}`);
}
}
- }
- // Enable auto-merge if configured
- if (autoMerge) {
- try {
- await githubClient.graphql(
- `mutation($prId: ID!) {
+ // Enable auto-merge if configured
+ if (autoMerge) {
+ try {
+ await githubClient.graphql(
+ `mutation($prId: ID!) {
enablePullRequestAutoMerge(input: {pullRequestId: $prId}) {
pullRequest {
id
}
}
}`,
- {
- prId: pullRequest.node_id,
- }
- );
- core.info(`Enabled auto-merge for pull request #${pullRequest.number}`);
- } catch (autoMergeError) {
- core.warning(`Failed to enable auto-merge for PR #${pullRequest.number}: ${autoMergeError instanceof Error ? autoMergeError.message : String(autoMergeError)}`);
+ {
+ prId: pullRequest.node_id,
+ }
+ );
+ core.info(`Enabled auto-merge for pull request #${pullRequest.number}`);
+ } catch (autoMergeError) {
+ core.warning(`Failed to enable auto-merge for PR #${pullRequest.number}: ${autoMergeError instanceof Error ? autoMergeError.message : String(autoMergeError)}`);
+ }
}
- }
- // Update the activation comment with PR link (if a comment was created)
- //
- // NOTE: we pass 'github' (global octokit) instead of githubClient (repo-scoped octokit) because the issue is created
- // in the same repo as the activation, so the global client has the correct context for updating the comment.
- await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number);
-
- // Close older pull requests if enabled (best-effort: errors are logged but do not fail the workflow)
- if (closeOlderPullRequestsEnabled) {
- if (workflowId || closeOlderKey) {
- const searchKey = closeOlderKey ? `gh-aw-close-key: ${closeOlderKey}` : `workflow-id: ${workflowId}`;
- core.info(`Attempting to close older pull requests for ${repoParts.owner}/${repoParts.repo}#${pullRequest.number} using ${searchKey}`);
- try {
- const closedPRs = await closeOlderPullRequests(github, repoParts.owner, repoParts.repo, workflowId, { number: pullRequest.number, html_url: pullRequest.html_url }, workflowName, runUrl, callerWorkflowId, closeOlderKey);
- if (closedPRs.length > 0) {
- core.info(`Closed ${closedPRs.length} older pull request(s)`);
+ // Update the activation comment with PR link (if a comment was created)
+ //
+ // NOTE: we pass 'github' (global octokit) instead of githubClient (repo-scoped octokit) because the issue is created
+ // in the same repo as the activation, so the global client has the correct context for updating the comment.
+ await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number);
+
+ // Close older pull requests if enabled (best-effort: errors are logged but do not fail the workflow)
+ if (closeOlderPullRequestsEnabled) {
+ if (workflowId || closeOlderKey) {
+ const searchKey = closeOlderKey ? `gh-aw-close-key: ${closeOlderKey}` : `workflow-id: ${workflowId}`;
+ core.info(`Attempting to close older pull requests for ${repoParts.owner}/${repoParts.repo}#${pullRequest.number} using ${searchKey}`);
+ try {
+ const closedPRs = await closeOlderPullRequests(github, repoParts.owner, repoParts.repo, workflowId, { number: pullRequest.number, html_url: pullRequest.html_url }, workflowName, runUrl, callerWorkflowId, closeOlderKey);
+ if (closedPRs.length > 0) {
+ core.info(`Closed ${closedPRs.length} older pull request(s)`);
+ }
+ } catch (error) {
+ // Log error but don't fail the workflow
+ core.warning(`Failed to close older pull requests: ${error instanceof Error ? error.message : String(error)}`);
}
- } catch (error) {
- // Log error but don't fail the workflow
- core.warning(`Failed to close older pull requests: ${error instanceof Error ? error.message : String(error)}`);
+ } else {
+ core.warning("Close older pull requests enabled but neither GH_AW_WORKFLOW_ID nor close-older-key is set - skipping");
}
- } else {
- core.warning("Close older pull requests enabled but neither GH_AW_WORKFLOW_ID nor close-older-key is set - skipping");
}
- }
- // Write summary to GitHub Actions summary
- await core.summary
- .addRaw(
- `
+ // Write summary to GitHub Actions summary
+ await core.summary
+ .addRaw(
+ `
## Pull Request
- **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url})
- **Branch**: \`${branchName}\`
- **Base Branch**: \`${baseBranch}\`
`
- )
- .write();
-
- // Push an extra empty commit if a token is configured and exactly 1 new commit was pushed.
- // This works around the GITHUB_TOKEN limitation where pushes don't trigger CI events.
- // Restricting to exactly 1 new commit prevents the CI trigger token being used on
- // multi-commit branches where workflow files may have been iteratively modified.
- const ciTriggerResult = await pushExtraEmptyCommit({
- branchName,
- repoOwner: repoParts.owner,
- repoName: repoParts.repo,
- newCommitCount,
- });
- if (ciTriggerResult.success && !ciTriggerResult.skipped) {
- core.info("Extra empty commit pushed - CI checks should start shortly");
- }
+ )
+ .write();
+
+ // Push an extra empty commit if a token is configured and exactly 1 new commit was pushed.
+ // This works around the GITHUB_TOKEN limitation where pushes don't trigger CI events.
+ // Restricting to exactly 1 new commit prevents the CI trigger token being used on
+ // multi-commit branches where workflow files may have been iteratively modified.
+ const ciTriggerResult = await pushExtraEmptyCommit({
+ branchName,
+ repoOwner: repoParts.owner,
+ repoName: repoParts.repo,
+ newCommitCount,
+ });
+ if (ciTriggerResult.success && !ciTriggerResult.skipped) {
+ core.info("Extra empty commit pushed - CI checks should start shortly");
+ }
- // Return success with PR details
- return {
- success: true,
- number: pullRequest.number,
- url: pullRequest.html_url,
- managedBody: body,
- branch_name: branchName,
- temporaryId: temporaryId,
- repo: itemRepo,
- };
- } catch (prError) {
- const errorMessage = prError instanceof Error ? prError.message : String(prError);
- core.warning(`Failed to create pull request: ${errorMessage}`);
+ // Return success with PR details
+ return {
+ success: true,
+ number: pullRequest.number,
+ url: pullRequest.html_url,
+ managedBody: body,
+ branch_name: branchName,
+ temporaryId: temporaryId,
+ repo: itemRepo,
+ };
+ } catch (prError) {
+ const errorMessage = prError instanceof Error ? prError.message : String(prError);
+ core.warning(`Failed to create pull request: ${errorMessage}`);
+
+ // Check if the error is the specific "GitHub actions is not permitted to create or approve pull requests" error
+ if (errorMessage.includes("GitHub Actions is not permitted to create or approve pull requests")) {
+ core.error(`Permission error: GitHub Actions is not permitted to create or approve pull requests. See FAQ: ${FAQ_CREATE_PR_PERMISSIONS_URL}`);
+
+ // Branch has already been pushed - create a fallback issue with a link to create the PR via GitHub UI
+ 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 createPrUrl = `${githubServer}/${repoParts.owner}/${repoParts.repo}/compare/${encodedBase}...${encodedHead}?expand=1&title=${encodeURIComponent(title)}`;
+
+ // Read patch content for preview
+ let patchPreview = "";
+ if (patchFilePath && fs.existsSync(patchFilePath)) {
+ const patchContent = fs.readFileSync(patchFilePath, "utf8");
+ patchPreview = generatePatchPreview(patchContent);
+ }
+
+ const fallbackTemplatePath = getPromptPath("pr_permission_denied_fallback.md");
+ const fallbackBody = renderTemplateFromFile(fallbackTemplatePath, {
+ body: issueSafeBody,
+ branch_name: branchName,
+ create_pr_url: createPrUrl,
+ faq_url: FAQ_CREATE_PR_PERMISSIONS_URL,
+ patch_preview: patchPreview,
+ });
+
+ try {
+ const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees);
+
+ core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);
+ await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number);
+
+ await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue");
+
+ return {
+ success: true,
+ fallback_used: true,
+ issue_number: issue.number,
+ issue_url: issue.html_url,
+ branch_name: branchName,
+ repo: itemRepo,
+ };
+ } catch (issueError) {
+ const error = `Failed to create pull request (permission denied) and failed to create fallback issue. PR error: ${errorMessage}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`;
+ core.error(error);
+ return {
+ success: false,
+ error,
+ error_type: "permission_denied",
+ };
+ }
+ }
- // Check if the error is the specific "GitHub actions is not permitted to create or approve pull requests" error
- if (errorMessage.includes("GitHub Actions is not permitted to create or approve pull requests")) {
- core.error(`Permission error: GitHub Actions is not permitted to create or approve pull requests. See FAQ: ${FAQ_CREATE_PR_PERMISSIONS_URL}`);
+ if (!fallbackAsIssue) {
+ // Fallback is disabled - return error without creating issue
+ core.error("fallback-as-issue is disabled - not creating fallback issue");
+ return {
+ success: false,
+ error: errorMessage,
+ error_type: "pr_creation_failed",
+ };
+ }
+
+ core.info("Falling back to creating an issue instead");
- // Branch has already been pushed - create a fallback issue with a link to create the PR via GitHub UI
+ // Create issue as fallback with enhanced body content
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 createPrUrl = `${githubServer}/${repoParts.owner}/${repoParts.repo}/compare/${encodedBase}...${encodedHead}?expand=1&title=${encodeURIComponent(title)}`;
+ const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${repoParts.owner}/${repoParts.repo}/tree/${branchName}`;
// Read patch content for preview
let patchPreview = "";
@@ -2282,14 +2403,21 @@ ${patchPreview}`;
patchPreview = generatePatchPreview(patchContent);
}
- const fallbackTemplatePath = getPromptPath("pr_permission_denied_fallback.md");
- const fallbackBody = renderTemplateFromFile(fallbackTemplatePath, {
- body: issueSafeBody,
- branch_name: branchName,
- create_pr_url: createPrUrl,
- faq_url: FAQ_CREATE_PR_PERMISSIONS_URL,
- patch_preview: patchPreview,
- });
+ const fallbackBody = `${issueSafeBody}
+
+---
+
+> [!NOTE]
+> This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}).
+>
+> **Original error:** ${errorMessage}
+
+To create the pull request manually:
+
+\`\`\`sh
+gh pr create --title "${title}" --base ${baseBranch} --head ${branchName} --repo ${repoParts.owner}/${repoParts.repo}
+\`\`\`
+${patchPreview}`;
try {
const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees);
@@ -2297,8 +2425,12 @@ ${patchPreview}`;
core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);
await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number);
+ // Update the activation comment with issue link (if a comment was created)
+ // NOTE: we pass 'github' (global octokit) instead of githubClient (repo-scoped octokit) because the issue is created
+ // in the same repo as the activation, so the global client has the correct context for updating the comment.
await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue");
+ // Return success with fallback flag
return {
success: true,
fallback_used: true,
@@ -2308,82 +2440,18 @@ ${patchPreview}`;
repo: itemRepo,
};
} catch (issueError) {
- const error = `Failed to create pull request (permission denied) and failed to create fallback issue. PR error: ${errorMessage}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`;
+ const error = `Failed to create both pull request and fallback issue. PR error: ${errorMessage}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`;
core.error(error);
return {
success: false,
error,
- error_type: "permission_denied",
};
}
}
-
- if (!fallbackAsIssue) {
- // Fallback is disabled - return error without creating issue
- core.error("fallback-as-issue is disabled - not creating fallback issue");
- return {
- success: false,
- error: errorMessage,
- error_type: "pr_creation_failed",
- };
- }
-
- core.info("Falling back to creating an issue instead");
-
- // 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}`;
-
- // Read patch content for preview
- let patchPreview = "";
- if (patchFilePath && fs.existsSync(patchFilePath)) {
- const patchContent = fs.readFileSync(patchFilePath, "utf8");
- patchPreview = generatePatchPreview(patchContent);
- }
-
- const fallbackBody = `${issueSafeBody}
-
----
-
-> [!NOTE]
-> This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}).
->
-> **Original error:** ${errorMessage}
-
-To create the pull request manually:
-
-\`\`\`sh
-gh pr create --title "${title}" --base ${baseBranch} --head ${branchName} --repo ${repoParts.owner}/${repoParts.repo}
-\`\`\`
-${patchPreview}`;
-
- try {
- const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(effectiveFallbackLabels), configAssignees);
-
- core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);
- await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number);
-
- // Update the activation comment with issue link (if a comment was created)
- // NOTE: we pass 'github' (global octokit) instead of githubClient (repo-scoped octokit) because the issue is created
- // in the same repo as the activation, so the global client has the correct context for updating the comment.
- await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue");
-
- // Return success with fallback flag
- return {
- success: true,
- fallback_used: true,
- issue_number: issue.number,
- issue_url: issue.html_url,
- branch_name: branchName,
- repo: itemRepo,
- };
- } catch (issueError) {
- const error = `Failed to create both pull request and fallback issue. PR error: ${errorMessage}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`;
- core.error(error);
- return {
- success: false,
- error,
- };
+ } finally {
+ // Restore original working directory after multi-repo subdirectory operations
+ if (repoCwd) {
+ process.chdir(originalCwd);
}
}
}; // End of handleCreatePullRequest
diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs
index b99e1d28e79..15f247ef5bb 100644
--- a/actions/setup/js/push_to_pull_request_branch.cjs
+++ b/actions/setup/js/push_to_pull_request_branch.cjs
@@ -428,14 +428,28 @@ async function main(config = {}) {
const workflowRepo = process.env.GITHUB_REPOSITORY || "";
if (itemRepo.toLowerCase() !== workflowRepo.toLowerCase()) {
core.info(`Cross-repo push: looking for checkout of ${itemRepo}`);
- const checkoutResult = findRepoCheckout(itemRepo, process.env.GITHUB_WORKSPACE, { allowedRepos: [...allowedRepos] });
- if (!checkoutResult.success) {
- return {
- success: false,
- error: `Repository '${itemRepo}' not found in workspace. Check out the target repo with actions/checkout and set its 'path' input so the checkout can be located. If checking out multiple repositories, ensure each actions/checkout step uses the appropriate 'path' input.`,
- };
+ // First try the checkout mapping (faster than scanning the workspace)
+ const checkoutMappingConfig = config.checkout_mapping || null;
+ if (checkoutMappingConfig) {
+ const targetLower = itemRepo.toLowerCase();
+ const mappedPath = checkoutMappingConfig[targetLower];
+ if (mappedPath) {
+ const path = require("path");
+ repoCwd = path.resolve(process.env.GITHUB_WORKSPACE || process.cwd(), mappedPath);
+ core.info(`Using checkout mapping: ${itemRepo} -> ${mappedPath}`);
+ }
+ }
+ // Fall back to workspace scan if not found in mapping
+ if (!repoCwd) {
+ const checkoutResult = findRepoCheckout(itemRepo, process.env.GITHUB_WORKSPACE, { allowedRepos: [...allowedRepos] });
+ if (!checkoutResult.success) {
+ return {
+ success: false,
+ error: `Repository '${itemRepo}' not found in workspace. Check out the target repo with actions/checkout and set its 'path' input so the checkout can be located. If checking out multiple repositories, ensure each actions/checkout step uses the appropriate 'path' input.`,
+ };
+ }
+ repoCwd = checkoutResult.path;
}
- repoCwd = checkoutResult.path;
core.info(`Found checkout for ${itemRepo} at: ${repoCwd}`);
}
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 fa9fb0a38c9..4c1b95782a8 100644
--- a/docs/src/content/docs/reference/safe-outputs-pull-requests.md
+++ b/docs/src/content/docs/reference/safe-outputs-pull-requests.md
@@ -96,8 +96,8 @@ If the base branch advances between agent start and `safe_outputs` apply, the PR
An older **patch transport** (`git format-patch` / `git am --3way`) is used when bundle data is unavailable. `--3way` resolves cleanly against an updated base when there are no conflicts; if it cannot, the patch is applied at the agent's original base commit and the PR UI shows the conflicts for manual resolution.
-:::note[Single cross-repo target]
-`safe_outputs` supports exactly **one** cross-repo target per run — the repository named in `target-repo`. Workflows that need to commit to multiple repositories in a single run are not currently supported.
+:::note[Cross-repo targets]
+When `target-repo` names a specific repository, `safe_outputs` checks out and applies changes to that single repository. When `target-repo: "*"` is used, the agent chooses the target repository at runtime and the `safe_outputs` job checks out **all** repositories listed in `checkout:` frontmatter into subdirectories (mirroring the agent job layout), enabling pull requests to multiple repositories in a single run.
:::
## Pull Request Updates (`update-pull-request:`)
@@ -281,7 +281,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.
+`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).
```yaml wrap
checkout:
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index 5b2f2354a37..8855cc52f53 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -6254,7 +6254,7 @@
},
"target-repo": {
"type": "string",
- "description": "Target repository in format 'owner/repo' for cross-repository pull request creation. Takes precedence over trial target repo settings."
+ "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."
},
"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\\'] }}').",
@@ -8040,7 +8040,7 @@
},
"target-repo": {
"type": "string",
- "description": "Target repository in format 'owner/repo' for cross-repository push to pull request branch. Takes precedence over trial target repo settings."
+ "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."
},
"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\\'] }}').",
diff --git a/pkg/workflow/compiler_safe_outputs_config_test.go b/pkg/workflow/compiler_safe_outputs_config_test.go
index b9c413bf5e2..0ac4f6ac765 100644
--- a/pkg/workflow/compiler_safe_outputs_config_test.go
+++ b/pkg/workflow/compiler_safe_outputs_config_test.go
@@ -2778,6 +2778,59 @@ func TestProtectTopLevelDotFolders(t *testing.T) {
}
}
+func TestInjectCheckoutMappingForWildcardTargetRepo(t *testing.T) {
+ t.Run("injects mapping when target-repo is wildcard", func(t *testing.T) {
+ data := &WorkflowData{
+ CheckoutConfigs: []*CheckoutConfig{
+ {Repository: "octocat/Hello-World", Path: "./hello-world"},
+ {Repository: "octocat/Spoon-Knife", Path: "./spoon-knife"},
+ },
+ }
+ handlerCfg := map[string]any{"target-repo": "*"}
+ injectCheckoutMapping("create_pull_request", handlerCfg, data)
+ mapping, ok := handlerCfg["checkout_mapping"].(map[string]string)
+ require.True(t, ok, "checkout_mapping should be a map[string]string")
+ assert.Equal(t, "hello-world", mapping["octocat/hello-world"])
+ assert.Equal(t, "spoon-knife", mapping["octocat/spoon-knife"])
+ })
+
+ t.Run("skips when target-repo is not wildcard", func(t *testing.T) {
+ data := &WorkflowData{
+ CheckoutConfigs: []*CheckoutConfig{
+ {Repository: "octocat/Hello-World", Path: "./hello-world"},
+ },
+ }
+ handlerCfg := map[string]any{"target-repo": "octocat/Hello-World"}
+ injectCheckoutMapping("create_pull_request", handlerCfg, data)
+ _, ok := handlerCfg["checkout_mapping"]
+ assert.False(t, ok, "checkout_mapping should not be injected for non-wildcard")
+ })
+
+ t.Run("skips wiki checkouts", func(t *testing.T) {
+ data := &WorkflowData{
+ CheckoutConfigs: []*CheckoutConfig{
+ {Repository: "octocat/Hello-World", Path: "./hello-world", Wiki: true},
+ },
+ }
+ handlerCfg := map[string]any{"target-repo": "*"}
+ injectCheckoutMapping("create_pull_request", handlerCfg, data)
+ _, ok := handlerCfg["checkout_mapping"]
+ assert.False(t, ok, "checkout_mapping should not include wiki checkouts")
+ })
+
+ t.Run("skips unrelated handlers", func(t *testing.T) {
+ data := &WorkflowData{
+ CheckoutConfigs: []*CheckoutConfig{
+ {Repository: "octocat/Hello-World", Path: "./hello-world"},
+ },
+ }
+ handlerCfg := map[string]any{"target-repo": "*"}
+ injectCheckoutMapping("create_issue", handlerCfg, data)
+ _, ok := handlerCfg["checkout_mapping"]
+ assert.False(t, ok, "checkout_mapping should not be injected for unrelated handlers")
+ })
+}
+
func TestHandlerConfigInjectsCurrentCheckoutPatchWorkspacePath(t *testing.T) {
compiler := NewCompiler()
workflowData := &WorkflowData{
diff --git a/pkg/workflow/compiler_safe_outputs_steps.go b/pkg/workflow/compiler_safe_outputs_steps.go
index 76fff0c7ae3..f8e1746e812 100644
--- a/pkg/workflow/compiler_safe_outputs_steps.go
+++ b/pkg/workflow/compiler_safe_outputs_steps.go
@@ -116,6 +116,16 @@ func (c *Compiler) buildSharedPRCheckoutSteps(data *WorkflowData) []string {
consolidatedSafeOutputsStepsLog.Printf("Using trialLogicalRepoSlug: %s", targetRepoSlug)
}
+ // Wildcard target-repo: the agent chooses the target repo at runtime.
+ // Instead of a single cross-repo checkout, emit checkout steps for ALL repos
+ // declared in checkout: configs (mirroring the agent job), so that any of them
+ // can be targeted by the agent's safe output messages at runtime.
+ // The JS handler uses findRepoCheckout() to locate the correct directory.
+ if targetRepoSlug == "*" {
+ consolidatedSafeOutputsStepsLog.Print("Wildcard target-repo: generating multi-repo checkout steps for safe_outputs")
+ return c.buildMultiRepoCheckoutSteps(data, checkoutMgr, checkoutToken, gitRemoteToken, condition)
+ }
+
// For cross-repo targets, override fetch-depth and sparse-checkout patterns
// from the checkout: config entry that targets the same repository. The agent
// job already uses these values; the safe_outputs job must mirror them so that
@@ -265,6 +275,170 @@ func (c *Compiler) buildSharedPRCheckoutSteps(data *WorkflowData) []string {
return steps
}
+// buildMultiRepoCheckoutSteps generates checkout steps for ALL repositories declared
+// in the checkout: config, mirroring what the agent job does. This is used when
+// target-repo is "*" (wildcard), meaning the agent decides at runtime which repository
+// to target. Each repository is checked out to its configured path (or workspace root
+// for the default checkout), so the JS handler can locate it via findRepoCheckout().
+//
+// The git credential configuration step sets up authentication for ALL checked-out
+// repositories, enabling push operations to any of them at runtime.
+func (c *Compiler) buildMultiRepoCheckoutSteps(data *WorkflowData, checkoutMgr *CheckoutManager, checkoutToken, gitRemoteToken string, condition ConditionNode) []string {
+ var steps []string
+ conditionStr := RenderCondition(condition)
+
+ // Step 1: Checkout the default (workspace root) repository.
+ // This mirrors the single-repo path but without a cross-repo repository: parameter.
+ defaultCheckout := checkoutMgr.GetDefaultCheckoutOverride()
+ defaultFetchDepth := 1
+ var defaultSparsePatterns []string
+ if defaultCheckout != nil {
+ if defaultCheckout.fetchDepth != nil {
+ defaultFetchDepth = *defaultCheckout.fetchDepth
+ }
+ if len(defaultCheckout.sparsePatterns) > 0 {
+ defaultSparsePatterns = defaultCheckout.sparsePatterns
+ }
+ }
+
+ // Checkout ref: use extracted base branch from agent output with event-context fallbacks.
+ // For comment-triggered privileged events, force checkout to trusted default branch.
+ const baseBranchFallbackExpr = "${{ (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') && github.event.repository.default_branch || steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}"
+ steps = append(steps, " - name: Checkout repository\n")
+ steps = append(steps, fmt.Sprintf(" if: %s\n", conditionStr))
+ steps = append(steps, fmt.Sprintf(" uses: %s\n", getActionPin("actions/checkout")))
+ steps = append(steps, " with:\n")
+ steps = append(steps, fmt.Sprintf(" ref: %s\n", baseBranchFallbackExpr))
+ steps = append(steps, fmt.Sprintf(" token: %s\n", checkoutToken))
+ steps = append(steps, " persist-credentials: false\n")
+ steps = append(steps, fmt.Sprintf(" fetch-depth: %d\n", defaultFetchDepth))
+ steps = appendSparseCheckoutLines(steps, defaultSparsePatterns)
+
+ // Step 2: Checkout additional repositories from checkout: configs into their paths.
+ // Only include entries that have a non-empty repository and path (cross-repo checkouts).
+ for _, cfg := range data.CheckoutConfigs {
+ if cfg == nil || cfg.Repository == "" || cfg.Path == "" {
+ continue
+ }
+ if cfg.Wiki {
+ // Wiki checkouts are not relevant for PR/push operations.
+ continue
+ }
+
+ entryFetchDepth := 1
+ if cfg.FetchDepth != nil {
+ entryFetchDepth = *cfg.FetchDepth
+ }
+ var entrySparsePatterns []string
+ if cfg.SparseCheckout != "" {
+ entrySparsePatterns = strings.Split(cfg.SparseCheckout, "\n")
+ }
+
+ // Use the safe-outputs token for authentication (consistent with single-repo path)
+ entryToken := checkoutToken
+ if cfg.GitHubToken != "" {
+ entryToken = cfg.GitHubToken
+ }
+
+ steps = append(steps, fmt.Sprintf(" - name: Checkout %s into %s\n", cfg.Repository, cfg.Path))
+ steps = append(steps, fmt.Sprintf(" if: %s\n", conditionStr))
+ steps = append(steps, fmt.Sprintf(" uses: %s\n", getActionPin("actions/checkout")))
+ steps = append(steps, " with:\n")
+ steps = append(steps, fmt.Sprintf(" repository: %s\n", cfg.Repository))
+ steps = append(steps, fmt.Sprintf(" path: %s\n", cfg.Path))
+ steps = append(steps, fmt.Sprintf(" token: %s\n", entryToken))
+ steps = append(steps, " persist-credentials: false\n")
+ steps = append(steps, fmt.Sprintf(" fetch-depth: %d\n", entryFetchDepth))
+ steps = appendSparseCheckoutLines(steps, entrySparsePatterns)
+
+ consolidatedSafeOutputsStepsLog.Printf("Added multi-repo checkout: %s -> %s", cfg.Repository, cfg.Path)
+ }
+
+ // Step 3: Configure Git credentials for ALL repositories.
+ // Set up authentication for the workspace root and each subdirectory checkout.
+ gitConfigSteps := []string{
+ " - name: Configure Git credentials\n",
+ fmt.Sprintf(" if: %s\n", conditionStr),
+ " env:\n",
+ " REPO_NAME: ${{ github.repository }}\n",
+ " SERVER_URL: ${{ github.server_url }}\n",
+ fmt.Sprintf(" GIT_TOKEN: %s\n", gitRemoteToken),
+ " run: |\n",
+ " git config --global user.email \"github-actions[bot]@users.noreply.github.com\"\n",
+ " git config --global user.name \"github-actions[bot]\"\n",
+ " git config --global am.keepcr true\n",
+ " # Re-authenticate git with GitHub token for workspace root\n",
+ " SERVER_URL_STRIPPED=\"${SERVER_URL#https://}\"\n",
+ " git remote set-url origin \"https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git\"\n",
+ }
+
+ // Also configure credentials for each subdirectory checkout
+ for _, cfg := range data.CheckoutConfigs {
+ if cfg == nil || cfg.Repository == "" || cfg.Path == "" || cfg.Wiki {
+ continue
+ }
+ gitConfigSteps = append(gitConfigSteps,
+ fmt.Sprintf(" # Re-authenticate git for %s\n", cfg.Repository),
+ fmt.Sprintf(" git -C \"%s\" remote set-url origin \"https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/%s.git\"\n", cfg.Path, cfg.Repository),
+ )
+ }
+
+ gitConfigSteps = append(gitConfigSteps,
+ " echo \"Git configured with standard GitHub Actions identity\"\n",
+ )
+ steps = append(steps, gitConfigSteps...)
+
+ // Step 4: Fetch additional refs for each repository that declares them.
+ for _, cfg := range data.CheckoutConfigs {
+ if cfg == nil || cfg.Repository == "" || cfg.Path == "" || cfg.Wiki {
+ continue
+ }
+ if entry := checkoutMgr.GetCheckoutForRepository(cfg.Repository); entry != nil && len(entry.fetchRefs) > 0 {
+ consolidatedSafeOutputsStepsLog.Printf("Adding fetch refs step for multi-repo target %s (%d refs)", cfg.Repository, len(entry.fetchRefs))
+ if fetchStep := buildSafeOutputsMultiRepoFetchRefsStep(cfg.Repository, cfg.Path, checkoutToken, entry.fetchRefs, entry.fetchDepth, conditionStr); fetchStep != "" {
+ steps = append(steps, fetchStep)
+ }
+ }
+ }
+
+ consolidatedSafeOutputsStepsLog.Printf("Added multi-repo checkout steps with condition: %s", condition.Render())
+ return steps
+}
+
+// buildSafeOutputsMultiRepoFetchRefsStep generates a conditional "Fetch additional refs"
+// step for a repository checked out into a subdirectory (multi-repo wildcard scenario).
+// Unlike buildSafeOutputsFetchRefsStep, this step targets a specific subdirectory via -C.
+func buildSafeOutputsMultiRepoFetchRefsStep(repoSlug, path, token string, fetchRefs []string, fetchDepth *int, condition string) string {
+ if len(fetchRefs) == 0 {
+ return ""
+ }
+ refspecs := make([]string, 0, len(fetchRefs))
+ for _, ref := range fetchRefs {
+ refspecs = append(refspecs, fmt.Sprintf("'%s'", fetchRefToRefspec(ref)))
+ }
+
+ depthFlag := ""
+ effectiveDepth := 1
+ if fetchDepth != nil {
+ effectiveDepth = *fetchDepth
+ }
+ if effectiveDepth > 0 {
+ depthFlag = fmt.Sprintf(" --depth=%d", effectiveDepth)
+ }
+
+ var sb strings.Builder
+ fmt.Fprintf(&sb, " - name: Fetch additional refs for %s\n", repoSlug)
+ if condition != "" {
+ fmt.Fprintf(&sb, " if: %s\n", condition)
+ }
+ sb.WriteString(" env:\n")
+ fmt.Fprintf(&sb, " GH_AW_FETCH_TOKEN: %s\n", token)
+ sb.WriteString(" run: |\n")
+ sb.WriteString(" header=$(printf \"x-access-token:%s\" \"${GH_AW_FETCH_TOKEN}\" | base64 -w 0)\n")
+ fmt.Fprintf(&sb, " git -C \"%s\" -c \"http.extraheader=Authorization: Basic ${header}\" fetch origin%s %s\n", path, depthFlag, strings.Join(refspecs, " "))
+ return sb.String()
+}
+
// buildSafeOutputsFetchRefsStep generates a conditional "Fetch additional refs" step
// for the safe_outputs job's cross-repo checkout.
//
diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go
index 61988e50d66..eff15dda04a 100644
--- a/pkg/workflow/safe_outputs_config.go
+++ b/pkg/workflow/safe_outputs_config.go
@@ -1744,6 +1744,7 @@ func (c *Compiler) addHandlerManagerConfigEnvVar(steps *[]string, data *Workflow
// 2. For auto-enabled handlers, include even with empty config
if handlerConfig != nil {
injectCurrentCheckoutPatchWorkspacePath(handlerName, handlerConfig, data)
+ injectCheckoutMapping(handlerName, handlerConfig, data)
// Augment protected-files protection with engine-specific files for handlers that use it.
if _, hasProtected := handlerConfig["protected_files"]; hasProtected {
// Extract per-handler exclusions set by the handler builder (sentinel key).
diff --git a/pkg/workflow/safe_outputs_config_generation.go b/pkg/workflow/safe_outputs_config_generation.go
index 3d1427395c9..fb559eed388 100644
--- a/pkg/workflow/safe_outputs_config_generation.go
+++ b/pkg/workflow/safe_outputs_config_generation.go
@@ -40,6 +40,7 @@ func generateSafeOutputsConfig(data *WorkflowData) (string, error) {
for handlerName, builder := range handlerRegistry {
if handlerCfg := builder(data.SafeOutputs); handlerCfg != nil {
injectCurrentCheckoutPatchWorkspacePath(handlerName, handlerCfg, data)
+ injectCheckoutMapping(handlerName, handlerCfg, data)
excludeFiles := ParseStringArrayFromConfig(handlerCfg, "_protected_files_exclude", nil)
// Strip the internal sentinel key used by the handler manager for compile-time
// exclusion processing — it must not be forwarded to the runtime config.json.
diff --git a/pkg/workflow/safe_outputs_patch_workspace.go b/pkg/workflow/safe_outputs_patch_workspace.go
index f431a43153b..e9a48687621 100644
--- a/pkg/workflow/safe_outputs_patch_workspace.go
+++ b/pkg/workflow/safe_outputs_patch_workspace.go
@@ -67,3 +67,51 @@ func normalizeCurrentCheckoutPatchPath(path string) string {
}
return filepath.ToSlash(path)
}
+
+// injectCheckoutMapping adds a checkout_mapping to handler config for create_pull_request
+// and push_to_pull_request_branch when target-repo is "*" (wildcard).
+// The mapping tells the JS handler where each repository is checked out on disk,
+// enabling it to operate on multiple repositories without dynamic git remote switching.
+//
+// The mapping is keyed by lowercase repo slug and values are relative paths within
+// GITHUB_WORKSPACE for cross-repo checkouts (entries with repository + path).
+func injectCheckoutMapping(handlerName string, handlerCfg map[string]any, data *WorkflowData) {
+ if handlerCfg == nil || data == nil {
+ return
+ }
+ if handlerName != "create_pull_request" && handlerName != "push_to_pull_request_branch" {
+ return
+ }
+
+ // Only inject when target-repo is wildcard
+ targetRepo := ""
+ if value, ok := handlerCfg["target-repo"].(string); ok {
+ targetRepo = strings.TrimSpace(value)
+ }
+ if targetRepo != "*" {
+ return
+ }
+
+ // Build the checkout mapping from checkout: configs
+ mapping := make(map[string]string)
+ for _, cfg := range data.CheckoutConfigs {
+ if cfg == nil || cfg.Repository == "" || cfg.Path == "" || cfg.Wiki {
+ continue
+ }
+ // Normalize repo slug to lowercase for consistent lookup
+ repoKey := strings.ToLower(strings.TrimSpace(cfg.Repository))
+ normalizedPath := normalizeCurrentCheckoutPatchPath(cfg.Path)
+ if normalizedPath != "" {
+ mapping[repoKey] = normalizedPath
+ }
+ }
+
+ // Only inject if there are actual cross-repo checkouts configured
+ if len(mapping) == 0 {
+ patchWorkspaceLog.Printf("No checkout mapping entries for handler=%s (wildcard target-repo but no cross-repo checkout: configs)", handlerName)
+ return
+ }
+
+ handlerCfg["checkout_mapping"] = mapping
+ patchWorkspaceLog.Printf("Injected checkout_mapping for handler=%s: %d entries", handlerName, len(mapping))
+}