diff --git a/pkg/workflow/compiler_safe_outputs_steps.go b/pkg/workflow/compiler_safe_outputs_steps.go index 2afffa9346..d97e565769 100644 --- a/pkg/workflow/compiler_safe_outputs_steps.go +++ b/pkg/workflow/compiler_safe_outputs_steps.go @@ -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", diff --git a/pkg/workflow/create_pull_request.go b/pkg/workflow/create_pull_request.go index 7213d5aca7..aa27623e79 100644 --- a/pkg/workflow/create_pull_request.go +++ b/pkg/workflow/create_pull_request.go @@ -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 diff --git a/pkg/workflow/create_pull_request_cross_repo_integration_test.go b/pkg/workflow/create_pull_request_cross_repo_integration_test.go new file mode 100644 index 0000000000..9a9090cfb0 --- /dev/null +++ b/pkg/workflow/create_pull_request_cross_repo_integration_test.go @@ -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") +} diff --git a/pkg/workflow/publish_assets.go b/pkg/workflow/publish_assets.go index 6e24e8778c..96c9e53aa9 100644 --- a/pkg/workflow/publish_assets.go +++ b/pkg/workflow/publish_assets.go @@ -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()...) diff --git a/pkg/workflow/push_to_pull_request_branch.go b/pkg/workflow/push_to_pull_request_branch.go index 816723c646..d379f9e704 100644 --- a/pkg/workflow/push_to_pull_request_branch.go +++ b/pkg/workflow/push_to_pull_request_branch.go @@ -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 != "" { steps = append(steps, " token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\n") } + return steps } diff --git a/pkg/workflow/yaml_generation.go b/pkg/workflow/yaml_generation.go index 49f9b27ad1..bedc92d22a 100644 --- a/pkg/workflow/yaml_generation.go +++ b/pkg/workflow/yaml_generation.go @@ -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",