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
33 changes: 27 additions & 6 deletions pkg/workflow/compiler_safe_outputs_steps.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,27 +106,48 @@ func (c *Compiler) buildSharedPRCheckoutSteps(data *WorkflowData) []string {
condition = BuildSafeOutputType("push_to_pull_request_branch")
}

// Determine target repository for checkout and git config
// Priority: create-pull-request target-repo > trialLogicalRepoSlug > default (source repo)
var targetRepoSlug string
if data.SafeOutputs.CreatePullRequests != nil && data.SafeOutputs.CreatePullRequests.TargetRepoSlug != "" {
targetRepoSlug = data.SafeOutputs.CreatePullRequests.TargetRepoSlug
consolidatedSafeOutputsStepsLog.Printf("Using target-repo from create-pull-request: %s", targetRepoSlug)
} else if c.trialMode && c.trialLogicalRepoSlug != "" {
targetRepoSlug = c.trialLogicalRepoSlug
consolidatedSafeOutputsStepsLog.Printf("Using trialLogicalRepoSlug: %s", targetRepoSlug)
}

// Step 1: Checkout repository with conditional execution
steps = append(steps, " - name: Checkout repository\n")
steps = append(steps, fmt.Sprintf(" if: %s\n", condition.Render()))
steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/checkout")))
steps = append(steps, " with:\n")

// Set repository parameter if checking out a different repository
if targetRepoSlug != "" {
steps = append(steps, fmt.Sprintf(" repository: %s\n", targetRepoSlug))
consolidatedSafeOutputsStepsLog.Printf("Added repository parameter: %s", targetRepoSlug)
}

steps = append(steps, fmt.Sprintf(" token: %s\n", checkoutToken))
steps = append(steps, " persist-credentials: false\n")
steps = append(steps, " fetch-depth: 1\n")
if c.trialMode {
if c.trialLogicalRepoSlug != "" {
steps = append(steps, fmt.Sprintf(" repository: %s\n", c.trialLogicalRepoSlug))
}
}

// Step 2: Configure Git credentials with conditional execution
// Security: Pass GitHub token through environment variable to prevent template injection

// Determine REPO_NAME value based on target repository
repoNameValue := "${{ github.repository }}"
if targetRepoSlug != "" {
repoNameValue = fmt.Sprintf("%q", targetRepoSlug)
consolidatedSafeOutputsStepsLog.Printf("Using target repo for REPO_NAME: %s", targetRepoSlug)
}

gitConfigSteps := []string{
" - name: Configure Git credentials\n",
fmt.Sprintf(" if: %s\n", condition.Render()),
" env:\n",
" REPO_NAME: ${{ github.repository }}\n",
fmt.Sprintf(" REPO_NAME: %s\n", repoNameValue),
" SERVER_URL: ${{ github.server_url }}\n",
fmt.Sprintf(" GIT_TOKEN: %s\n", gitRemoteToken),
" run: |\n",
Expand Down
6 changes: 4 additions & 2 deletions pkg/workflow/create_pull_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,12 @@ func (c *Compiler) buildCreateOutputPullRequestJob(data *WorkflowData, mainJobNa
preSteps = append(preSteps, " path: /tmp/gh-aw/\n")

// Step 2: Checkout repository
preSteps = buildCheckoutRepository(preSteps, c)
preSteps = buildCheckoutRepository(preSteps, c, data.SafeOutputs.CreatePullRequests.TargetRepoSlug)

// Step 3: Configure Git credentials
preSteps = append(preSteps, c.generateGitConfigurationSteps()...)
// Pass the target repo to configure git remote correctly for cross-repo operations
gitToken := "${{ github.token }}"
preSteps = append(preSteps, c.generateGitConfigurationStepsWithToken(gitToken, data.SafeOutputs.CreatePullRequests.TargetRepoSlug)...)

// Build custom environment variables specific to create-pull-request
var customEnvVars []string
Expand Down
147 changes: 147 additions & 0 deletions pkg/workflow/create_pull_request_cross_repo_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
//go:build integration

package workflow

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestCreatePullRequestCrossRepoCheckout tests that target-repo properly configures checkout and git
func TestCreatePullRequestCrossRepoCheckout(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "cross-repo-checkout-test")
require.NoError(t, err, "Failed to create temp dir")
defer os.RemoveAll(tmpDir)

// Create test workflow with cross-repo target
workflowContent := `---
on: push
permissions:
contents: read
actions: read
issues: read
pull-requests: read
engine: copilot
safe-outputs:
create-pull-request:
target-repo: "microsoft/vscode-docs"
base-branch: vnext
draft: true
---

# Cross-Repo Test Workflow

Create a pull request in a different repository.
`

workflowPath := filepath.Join(tmpDir, "cross-repo.md")
require.NoError(t, os.WriteFile(workflowPath, []byte(workflowContent), 0644), "Failed to write workflow file")

// Compile the workflow
compiler := NewCompiler()
require.NoError(t, compiler.CompileWorkflow(workflowPath), "Failed to compile workflow")

// Read the compiled output
outputFile := filepath.Join(tmpDir, "cross-repo.lock.yml")
compiledBytes, err := os.ReadFile(outputFile)
require.NoError(t, err, "Failed to read compiled output")

compiledContent := string(compiledBytes)

// Test 1: Verify repository parameter is set in actions/checkout
assert.Contains(t, compiledContent, "repository: microsoft/vscode-docs",
"Expected checkout to specify target repository")

// Test 2: Verify REPO_NAME environment variable is set to target repo
assert.Contains(t, compiledContent, `REPO_NAME: "microsoft/vscode-docs"`,
"Expected REPO_NAME env var to be set to target repository")

// Test 3: Verify token is included for cross-repo checkout
assert.Contains(t, compiledContent, "token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}",
"Expected token to be set for cross-repo checkout")

// Test 4: Verify it does NOT use the default github.repository
checkoutSection := extractCheckoutSection(compiledContent)
assert.NotContains(t, checkoutSection, "github.repository",
"Checkout section should not reference github.repository when target-repo is set")
}

// TestCreatePullRequestSameRepoCheckout tests that without target-repo, we use default checkout
func TestCreatePullRequestSameRepoCheckout(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "same-repo-checkout-test")
require.NoError(t, err, "Failed to create temp dir")
defer os.RemoveAll(tmpDir)

// Create test workflow without target-repo
workflowContent := `---
on: push
permissions:
contents: read
actions: read
issues: read
pull-requests: read
engine: copilot
safe-outputs:
create-pull-request:
draft: true
---

# Same-Repo Test Workflow

Create a pull request in the same repository.
`

workflowPath := filepath.Join(tmpDir, "same-repo.md")
require.NoError(t, os.WriteFile(workflowPath, []byte(workflowContent), 0644), "Failed to write workflow file")

// Compile the workflow
compiler := NewCompiler()
require.NoError(t, compiler.CompileWorkflow(workflowPath), "Failed to compile workflow")

// Read the compiled output
outputFile := filepath.Join(tmpDir, "same-repo.lock.yml")
compiledBytes, err := os.ReadFile(outputFile)
require.NoError(t, err, "Failed to read compiled output")

compiledContent := string(compiledBytes)

// Test 1: Verify no explicit repository parameter (uses default)
checkoutSection := extractCheckoutSection(compiledContent)
assert.NotContains(t, checkoutSection, "repository:",
"Checkout section should not have explicit repository when using source repo")

// Test 2: Verify REPO_NAME uses github.repository expression
assert.Contains(t, compiledContent, "REPO_NAME: ${{ github.repository }}",
"Expected REPO_NAME to use github.repository expression for same-repo")

// Test 3: Verify no token in checkout (not needed for same repo)
assert.NotContains(t, checkoutSection, "token:",
"Checkout section should not have token for same-repo checkout")
}

// extractCheckoutSection extracts the checkout step from compiled YAML for inspection
func extractCheckoutSection(content string) string {
lines := strings.Split(content, "\n")
inCheckout := false
var checkoutLines []string

for _, line := range lines {
if strings.Contains(line, "name: Checkout repository") {
inCheckout = true
}
if inCheckout {
checkoutLines = append(checkoutLines, line)
// Stop at the next step (less indentation than " -")
if strings.HasPrefix(line, " - name:") && !strings.Contains(line, "Checkout repository") {
break
}
}
}

return strings.Join(checkoutLines, "\n")
}
2 changes: 1 addition & 1 deletion pkg/workflow/publish_assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ func (c *Compiler) buildUploadAssetsJob(data *WorkflowData, mainJobName string,
}

// Step 1: Checkout repository
preSteps = buildCheckoutRepository(preSteps, c)
preSteps = buildCheckoutRepository(preSteps, c, "")

// Step 2: Configure Git credentials
preSteps = append(preSteps, c.generateGitConfigurationSteps()...)
Expand Down
34 changes: 25 additions & 9 deletions pkg/workflow/push_to_pull_request_branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,38 @@ type PushToPullRequestBranchConfig struct {
CommitTitleSuffix string `yaml:"commit-title-suffix,omitempty"` // Optional suffix to append to generated commit titles
}

func buildCheckoutRepository(steps []string, c *Compiler) []string {
// buildCheckoutRepository generates a checkout step with optional target repository
// Parameters:
// - steps: existing steps to append to
// - c: compiler instance for trialMode checks
// - targetRepoSlug: optional target repository (e.g., "org/repo") for cross-repo operations
// If empty, checks out the source repository (github.repository)
// If set, checks out the specified target repository
func buildCheckoutRepository(steps []string, c *Compiler, targetRepoSlug string) []string {
steps = append(steps, " - name: Checkout repository\n")
steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/checkout")))
steps = append(steps, " with:\n")

// Determine which repository to check out
// Priority: targetRepoSlug > trialLogicalRepoSlug > default (source repo)
effectiveTargetRepo := targetRepoSlug
if c.trialMode && c.trialLogicalRepoSlug != "" {
effectiveTargetRepo = c.trialLogicalRepoSlug
}

// Set repository parameter if we're checking out a different repo
if effectiveTargetRepo != "" {
steps = append(steps, fmt.Sprintf(" repository: %s\n", effectiveTargetRepo))
}

steps = append(steps, " persist-credentials: false\n")
steps = append(steps, " fetch-depth: 0\n")
if c.trialMode {
if c.trialLogicalRepoSlug != "" {
steps = append(steps, fmt.Sprintf(" repository: %s\n", c.trialLogicalRepoSlug))
// trialTargetRepoName := strings.Split(c.trialLogicalRepoSlug, "/")
// if len(trialTargetRepoName) == 2 {
// steps = append(steps, fmt.Sprintf(" path: %s\n", trialTargetRepoName[1]))
// }
}

// Add token for trial mode or when checking out a different repository
if c.trialMode || targetRepoSlug != "" {
Comment on lines +49 to +50
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

The condition for adding a token should consider effectiveTargetRepo instead of just targetRepoSlug. If trialLogicalRepoSlug is set (in trial mode), a token is already included, but the current logic may not include a token when only targetRepoSlug is set without trial mode.

However, after the priority bug is fixed, this condition should be: if effectiveTargetRepo != "" to properly handle all cases where we're checking out a different repository.

This issue also appears on line 36 of the same file.

Suggested change
// Add token for trial mode or when checking out a different repository
if c.trialMode || targetRepoSlug != "" {
// Add token when checking out a different repository
if effectiveTargetRepo != "" {

Copilot uses AI. Check for mistakes.
steps = append(steps, " token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\n")
}

return steps
}

Expand Down
21 changes: 18 additions & 3 deletions pkg/workflow/yaml_generation.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,30 @@ import "fmt"

// generateGitConfigurationSteps generates standardized git credential setup as string steps
func (c *Compiler) generateGitConfigurationSteps() []string {
return c.generateGitConfigurationStepsWithToken("${{ github.token }}")
return c.generateGitConfigurationStepsWithToken("${{ github.token }}", "")
}

// generateGitConfigurationStepsWithToken generates git credential setup with a custom token
func (c *Compiler) generateGitConfigurationStepsWithToken(token string) []string {
// and optional target repository for cross-repo operations
// Parameters:
// - token: GitHub token to use for authentication
// - targetRepoSlug: optional target repository (e.g., "org/repo") for cross-repo operations
// If empty, uses source repository (github.repository)
// If set, configures git remote to point to the target repository
func (c *Compiler) generateGitConfigurationStepsWithToken(token string, targetRepoSlug string) []string {
// Determine which repository to configure git remote for
// Priority: targetRepoSlug > trialLogicalRepoSlug > default (source repo)
repoNameValue := "${{ github.repository }}"
if targetRepoSlug != "" {
repoNameValue = fmt.Sprintf("%q", targetRepoSlug)
} else if c.trialMode && c.trialLogicalRepoSlug != "" {
repoNameValue = fmt.Sprintf("%q", c.trialLogicalRepoSlug)
}

return []string{
" - name: Configure Git credentials\n",
" env:\n",
" REPO_NAME: ${{ github.repository }}\n",
fmt.Sprintf(" REPO_NAME: %s\n", repoNameValue),
" SERVER_URL: ${{ github.server_url }}\n",
" run: |\n",
" git config --global user.email \"github-actions[bot]@users.noreply.github.com\"\n",
Expand Down
Loading