Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,542 changes: 1,305 additions & 1,237 deletions actions/setup/js/create_pull_request.cjs

Large diffs are not rendered by default.

28 changes: 21 additions & 7 deletions actions/setup/js/push_to_pull_request_branch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}

Expand Down
6 changes: 3 additions & 3 deletions docs/src/content/docs/reference/safe-outputs-pull-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:`)
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\\'] }}').",
Expand Down Expand Up @@ -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\\'] }}').",
Expand Down
53 changes: 53 additions & 0 deletions pkg/workflow/compiler_safe_outputs_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
174 changes: 174 additions & 0 deletions pkg/workflow/compiler_safe_outputs_steps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
//
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/safe_outputs_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/safe_outputs_config_generation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading