diff --git a/pkg/workflow/checkout_step_generator.go b/pkg/workflow/checkout_step_generator.go index 6216c8cbc99..993593f17c6 100644 --- a/pkg/workflow/checkout_step_generator.go +++ b/pkg/workflow/checkout_step_generator.go @@ -25,6 +25,10 @@ func (cm *CheckoutManager) GenerateCheckoutAppTokenSteps(c *Compiler, permission stepID := fmt.Sprintf("checkout-app-token-%d", i) for _, step := range appSteps { modified := strings.ReplaceAll(step, "id: safe-outputs-app-token", "id: "+stepID) + // Rename the step to make it unique when multiple checkouts use app auth. + // This prevents duplicate step name errors when more than one checkout entry + // falls back to the top-level github-app (or has its own github-app configured). + modified = strings.ReplaceAll(modified, "name: Generate GitHub App token", fmt.Sprintf("name: Generate GitHub App token for checkout (%d)", i)) steps = append(steps, modified) } } diff --git a/pkg/workflow/duplicate_step_validation_integration_test.go b/pkg/workflow/duplicate_step_validation_integration_test.go index 92283543ff3..d8a4ceed56f 100644 --- a/pkg/workflow/duplicate_step_validation_integration_test.go +++ b/pkg/workflow/duplicate_step_validation_integration_test.go @@ -89,3 +89,97 @@ This workflow tests that duplicate checkout steps are properly deduplicated. t.Logf("✓ Duplicate step validation working correctly: found %d checkout step(s) in safe_outputs job (deduplicated)", checkoutCount) } + +// TestDuplicateStepValidation_CheckoutPlusGitHubApp_Integration tests that combining +// a top-level github-app with multiple cross-repo checkouts and tools.github does not +// produce duplicate 'Generate GitHub App token' steps in the activation job. +// +// When multiple checkout entries all fall back to the top-level github-app, +// each minting step previously received the same name, triggering the duplicate +// step validation error ("compiler bug: duplicate step 'Generate GitHub App token'"). +func TestDuplicateStepValidation_CheckoutPlusGitHubApp_Integration(t *testing.T) { + tmpDir := testutil.TempDir(t, "duplicate-checkout-token-test") + + // Workflow that combines all three conditions that triggered the bug: + // 1. Top-level github-app: (used as fallback for all token-minting operations) + // 2. Two cross-repo checkout: entries (both fall back to the top-level github-app) + // 3. tools.github: with mode: remote + mdContent := `--- +on: + issues: + types: [opened] +engine: claude +strict: false +permissions: + contents: read + issues: read + pull-requests: read + +github-app: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + repositories: ["side-repo", "target-repo"] + +checkout: + - repository: myorg/target-repo + ref: main + - repository: myorg/side-repo + ref: main + +tools: + github: + mode: remote + toolsets: [default] +--- + +# Test Workflow + +This workflow tests that multiple checkouts + top-level github-app + tools.github +compile without duplicate 'Generate GitHub App token' step errors in the activation job. +` + + mdFile := filepath.Join(tmpDir, "test-checkout-github-app.md") + err := os.WriteFile(mdFile, []byte(mdContent), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Compile workflow — must succeed so the generated lock file can be validated. + compiler := NewCompiler() + err = compiler.CompileWorkflow(mdFile) + if err != nil { + if strings.Contains(err.Error(), "duplicate step") { + t.Fatalf("Regression: duplicate step error when combining multiple checkouts + top-level github-app: %v", err) + } + t.Fatalf("Compilation failed unexpectedly before lock-file assertions could run: %v", err) + } + + // Read the generated lock file and verify the activation job has unique step names + lockFile := stringutil.MarkdownToLockFile(mdFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + lockContentStr := string(lockContent) + + // Both checkout token minting steps should be present with unique names. + // The step names are "Generate GitHub App token for checkout (N)" — one per checkout entry. + count0 := strings.Count(lockContentStr, "name: Generate GitHub App token for checkout (0)") + count1 := strings.Count(lockContentStr, "name: Generate GitHub App token for checkout (1)") + if count0 != 1 { + t.Errorf("Expected exactly 1 'Generate GitHub App token for checkout (0)' step, got %d", count0) + } + if count1 != 1 { + t.Errorf("Expected exactly 1 'Generate GitHub App token for checkout (1)' step, got %d", count1) + } + + // Exactly one generic "Generate GitHub App token" step is expected — for the GitHub MCP server + // in the agent job (id: github-mcp-app-token). If more than one appears, that means a + // checkout minting step was not renamed, which would cause a duplicate-name error. + genericCount := strings.Count(lockContentStr, "name: Generate GitHub App token\n") + if genericCount > 1 { + t.Errorf("Found %d generic 'Generate GitHub App token' steps; checkout steps must use unique names to avoid duplicates", genericCount) + } + + t.Logf("✓ No duplicate token steps: checkout (0) count=%d, checkout (1) count=%d, generic=%d", count0, count1, genericCount) +}