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
4 changes: 4 additions & 0 deletions pkg/workflow/checkout_step_generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
94 changes: 94 additions & 0 deletions pkg/workflow/duplicate_step_validation_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}