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
75 changes: 44 additions & 31 deletions pkg/cli/copilot_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,9 @@ func upgradeCopilotSetupSteps(verbose bool, actionMode workflow.ActionMode, vers
return ensureCopilotSetupStepsWithUpgrade(verbose, actionMode, version, true)
}

// ensureCopilotSetupStepsWithUpgrade creates or updates .github/workflows/copilot-setup-steps.yml
// When upgradeVersion is true, it will update existing actions/setup-cli versions
// ensureCopilotSetupStepsWithUpgrade creates .github/workflows/copilot-setup-steps.yml
// If the file already exists, it renders console instructions instead of editing
// When upgradeVersion is true and called from upgrade command, this is a special case
func ensureCopilotSetupStepsWithUpgrade(verbose bool, actionMode workflow.ActionMode, version string, upgradeVersion bool) error {
copilotSetupLog.Printf("Creating copilot-setup-steps.yml with action mode: %s, version: %s, upgradeVersion: %v", actionMode, version, upgradeVersion)

Comment on lines +140 to 145
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

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

In ensureCopilotSetupStepsWithUpgrade, the os.Stat(setupStepsPath) existence check later in this function treats any non-nil error as "file doesn't exist" and will fall through to os.WriteFile(...). If Stat fails for reasons other than ErrNotExist (permissions/IO), this can overwrite an existing workflow—contradicting the new "don’t edit existing files" init behavior. Recommend handling ErrNotExist explicitly and returning an error on other Stat failures.

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -168,9 +169,10 @@ func ensureCopilotSetupStepsWithUpgrade(verbose bool, actionMode workflow.Action
(strings.Contains(contentStr, "Install gh-aw extension") && strings.Contains(contentStr, "curl -fsSL"))
hasActionInstall := strings.Contains(contentStr, "actions/setup-cli")

// If we have an install step and upgradeVersion is true, attempt to upgrade the version
// If we have an install step and upgradeVersion is true, this is from upgrade command
// In this case, we still update the file for backward compatibility
if (hasLegacyInstall || hasActionInstall) && upgradeVersion {
copilotSetupLog.Print("Extension install step exists, attempting version upgrade")
copilotSetupLog.Print("Extension install step exists, attempting version upgrade (upgrade command)")

// Parse existing workflow
var workflow Workflow
Expand Down Expand Up @@ -209,43 +211,22 @@ func ensureCopilotSetupStepsWithUpgrade(verbose bool, actionMode workflow.Action
return nil
}

// File exists - render instructions instead of editing
if hasLegacyInstall || hasActionInstall {
copilotSetupLog.Print("Extension install step already exists, skipping update")
copilotSetupLog.Print("Extension install step already exists, file is up to date")
if verbose {
fmt.Fprintf(os.Stderr, "Skipping %s (already has gh-aw extension install step)\n", setupStepsPath)
Comment on lines +216 to 218
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

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

The message "Extension install step already exists, file is up to date" is stronger than what the code verifies (it only checks for presence of an install step substring, not correctness/version/mode). Consider rewording to something like "install step detected; leaving file unchanged" to avoid misleading logs/output.

Suggested change
copilotSetupLog.Print("Extension install step already exists, file is up to date")
if verbose {
fmt.Fprintf(os.Stderr, "Skipping %s (already has gh-aw extension install step)\n", setupStepsPath)
copilotSetupLog.Print("Extension install step detected; leaving file unchanged")
if verbose {
fmt.Fprintf(os.Stderr, "Skipping %s (detected existing gh-aw extension install step)\n", setupStepsPath)

Copilot uses AI. Check for mistakes.
}
return nil
}

// Parse existing workflow
var workflow Workflow
if err := yaml.Unmarshal(content, &workflow); err != nil {
return fmt.Errorf("failed to parse existing copilot-setup-steps.yml: %w", err)
}

// Inject the extension install step
copilotSetupLog.Print("Injecting extension install step into existing file")
if err := injectExtensionInstallStep(&workflow, actionMode, version); err != nil {
return fmt.Errorf("failed to inject extension install step: %w", err)
}

// Marshal back to YAML
updatedContent, err := yaml.Marshal(&workflow)
if err != nil {
return fmt.Errorf("failed to marshal updated workflow: %w", err)
}

if err := os.WriteFile(setupStepsPath, updatedContent, 0600); err != nil {
return fmt.Errorf("failed to update copilot-setup-steps.yml: %w", err)
}
copilotSetupLog.Printf("Updated file with extension install step: %s", setupStepsPath)

if verbose {
fmt.Fprintf(os.Stderr, "Updated %s with gh-aw extension install step\n", setupStepsPath)
}
// File exists but needs update - render instructions
copilotSetupLog.Print("File exists without install step, rendering update instructions instead of editing")
renderCopilotSetupUpdateInstructions(setupStepsPath, actionMode, version)
return nil
}

// File doesn't exist - create it
if err := os.WriteFile(setupStepsPath, []byte(generateCopilotSetupStepsYAML(actionMode, version)), 0600); err != nil {
return fmt.Errorf("failed to write copilot-setup-steps.yml: %w", err)
}
Expand All @@ -254,6 +235,38 @@ func ensureCopilotSetupStepsWithUpgrade(verbose bool, actionMode workflow.Action
return nil
}

// renderCopilotSetupUpdateInstructions renders console instructions for updating copilot-setup-steps.yml
func renderCopilotSetupUpdateInstructions(filePath string, actionMode workflow.ActionMode, version string) {
fmt.Fprintln(os.Stderr)
fmt.Fprintf(os.Stderr, "%s %s\n",
"ℹ",
fmt.Sprintf("Existing file detected: %s", filePath))
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "To enable GitHub Copilot Agent integration, please add the following steps")
fmt.Fprintln(os.Stderr, "to the 'copilot-setup-steps' job in your .github/workflows/copilot-setup-steps.yml file:")
fmt.Fprintln(os.Stderr)

// Determine the action reference
actionRef := "@main"
if actionMode.IsRelease() && version != "" && version != "dev" {
actionRef = "@" + version
}

if actionMode.IsRelease() {
fmt.Fprintln(os.Stderr, " - name: Checkout repository")
fmt.Fprintln(os.Stderr, " uses: actions/checkout@v4")
fmt.Fprintf(os.Stderr, " - name: Install gh-aw extension\n")
fmt.Fprintf(os.Stderr, " uses: github/gh-aw/actions/setup-cli%s\n", actionRef)
fmt.Fprintln(os.Stderr, " with:")
fmt.Fprintf(os.Stderr, " version: %s\n", version)
} else {
fmt.Fprintln(os.Stderr, " - name: Install gh-aw extension")
fmt.Fprintln(os.Stderr, " run: |")
fmt.Fprintln(os.Stderr, " curl -fsSL https://raw.githubusercontent.com/github/gh-aw/refs/heads/main/install-gh-aw.sh | bash")
}
fmt.Fprintln(os.Stderr)
}

// upgradeSetupCliVersion upgrades the version in existing actions/setup-cli steps
// Returns true if any upgrades were made, false otherwise
func upgradeSetupCliVersion(workflow *Workflow, actionMode workflow.ActionMode, version string) (bool, error) {
Expand Down
62 changes: 33 additions & 29 deletions pkg/cli/copilot_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func TestEnsureCopilotSetupSteps(t *testing.T) {
},
},
{
name: "injects extension install into existing workflow",
name: "renders instructions for existing workflow without install step",
existingWorkflow: &Workflow{
Name: "Copilot Setup Steps",
On: "workflow_dispatch",
Expand All @@ -93,7 +93,7 @@ func TestEnsureCopilotSetupSteps(t *testing.T) {
verbose: false,
wantErr: false,
validateContent: func(t *testing.T, content []byte) {
Comment on lines 73 to 95
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

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

This test asserts the workflow file remains unchanged, but it doesn’t assert that update instructions were rendered. Since the new behavior is "render to console instead of editing", please capture os.Stderr and verify it includes a recognizable instruction header (e.g. "Existing file detected") and the expected step snippet for the chosen action mode.

Copilot uses AI. Check for mistakes.
// Unmarshal YAML content into Workflow struct for structured validation
// File should NOT be modified - should remain with only 2 steps
var wf Workflow
if err := yaml.Unmarshal(content, &wf); err != nil {
t.Fatalf("Failed to unmarshal workflow YAML: %v", err)
Expand All @@ -103,13 +103,14 @@ func TestEnsureCopilotSetupSteps(t *testing.T) {
t.Fatalf("Expected job 'copilot-setup-steps' not found")
}

// Extension install and verify steps should be injected at the beginning
if len(job.Steps) < 3 {
t.Fatalf("Expected at least 3 steps after injection (1 injected + 2 existing), got %d", len(job.Steps))
// File should remain unchanged with only 2 existing steps
if len(job.Steps) != 2 {
t.Errorf("Expected 2 steps (file should not be modified), got %d", len(job.Steps))
}

if job.Steps[0].Name != "Install gh-aw extension" {
t.Errorf("Expected first step to be 'Install gh-aw extension', got %q", job.Steps[0].Name)
// Verify the install step was NOT injected
if job.Steps[0].Name == "Install gh-aw extension" {
t.Errorf("Expected 'Install gh-aw extension' step to NOT be injected (instructions should be rendered)")
}
},
},
Expand Down Expand Up @@ -729,34 +730,32 @@ jobs:
t.Fatalf("Failed to write existing workflow: %v", err)
}

// Update with release mode
// Call with release mode - should render instructions instead of modifying
testVersion := "v3.0.0"
err = ensureCopilotSetupSteps(false, workflow.ActionModeRelease, testVersion)
if err != nil {
t.Fatalf("ensureCopilotSetupSteps() failed: %v", err)
}

// Read updated file
// Read file - should remain unchanged
content, err := os.ReadFile(setupStepsPath)
if err != nil {
t.Fatalf("Failed to read updated file: %v", err)
t.Fatalf("Failed to read file: %v", err)
}

contentStr := string(content)

// Verify release mode injection
if !strings.Contains(contentStr, "actions/setup-cli@v3.0.0") {
t.Errorf("Expected injected action with @v3.0.0 tag, got:\n%s", contentStr)
// Verify file was NOT modified - should remain identical to existingContent
if contentStr != existingContent {
t.Errorf("Expected file to remain unchanged (instructions should be rendered instead), got:\n%s", contentStr)
}
if !strings.Contains(contentStr, "version: v3.0.0") {
t.Errorf("Expected version: v3.0.0 parameter, got:\n%s", contentStr)
}
if !strings.Contains(contentStr, "actions/checkout@v4") {
t.Errorf("Expected checkout step to be injected")

// Verify the install step was NOT injected
if strings.Contains(contentStr, "actions/setup-cli") {
t.Errorf("Expected 'actions/setup-cli' to NOT be injected (instructions should be rendered)")
}
// Verify original step is preserved
if !strings.Contains(contentStr, "Some other step") {
t.Errorf("Expected original step to be preserved")
if strings.Contains(contentStr, "Install gh-aw extension") {
t.Errorf("Expected 'Install gh-aw extension' step to NOT be injected (instructions should be rendered)")
}
}

Expand Down Expand Up @@ -796,26 +795,31 @@ jobs:
t.Fatalf("Failed to write existing workflow: %v", err)
}

// Update with dev mode
// Call with dev mode - should render instructions instead of modifying
err = ensureCopilotSetupSteps(false, workflow.ActionModeDev, "dev")
if err != nil {
t.Fatalf("ensureCopilotSetupSteps() failed: %v", err)
}

// Read updated file
// Read file - should remain unchanged
content, err := os.ReadFile(setupStepsPath)
if err != nil {
t.Fatalf("Failed to read updated file: %v", err)
t.Fatalf("Failed to read file: %v", err)
}

contentStr := string(content)

// Verify dev mode injection
if !strings.Contains(contentStr, "curl -fsSL") {
t.Errorf("Expected curl command in dev mode")
// Verify file was NOT modified - should remain identical to existingContent
if contentStr != existingContent {
t.Errorf("Expected file to remain unchanged (instructions should be rendered instead), got:\n%s", contentStr)
}
if !strings.Contains(contentStr, "install-gh-aw.sh") {
t.Errorf("Expected install-gh-aw.sh in dev mode")

// Verify the install step was NOT injected
if strings.Contains(contentStr, "curl -fsSL") {
t.Errorf("Expected 'curl' command to NOT be injected (instructions should be rendered)")
}
if strings.Contains(contentStr, "install-gh-aw.sh") {
t.Errorf("Expected 'install-gh-aw.sh' to NOT be injected (instructions should be rendered)")
}
if strings.Contains(contentStr, "actions/setup-cli") {
t.Errorf("Did not expect actions/setup-cli in dev mode")
Expand Down
39 changes: 14 additions & 25 deletions pkg/cli/init_mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ func TestInitRepository_MCP_Idempotent(t *testing.T) {
}
}

func TestEnsureMCPConfig_UpdatesExisting(t *testing.T) {
func TestEnsureMCPConfig_RendersInstructions(t *testing.T) {
// Create a temporary directory for testing
tempDir := testutil.TempDir(t, "test-*")

Expand Down Expand Up @@ -201,7 +201,7 @@ func TestEnsureMCPConfig_UpdatesExisting(t *testing.T) {
t.Fatalf("ensureMCPConfig() returned error: %v", err)
}

// Verify the config now contains both servers
// Verify the config was NOT modified (file should remain unchanged)
content, err := os.ReadFile(mcpConfigPath)
if err != nil {
t.Fatalf("Failed to read mcp.json: %v", err)
Expand All @@ -212,17 +212,18 @@ func TestEnsureMCPConfig_UpdatesExisting(t *testing.T) {
t.Fatalf("Failed to parse mcp.json: %v", err)
}

// Check both servers exist
// Check that other-server still exists
if _, exists := config.Servers["other-server"]; !exists {
t.Errorf("Expected existing 'other-server' to be preserved")
}

if _, exists := config.Servers["github-agentic-workflows"]; !exists {
t.Errorf("Expected 'github-agentic-workflows' server to be added")
// Check that github-agentic-workflows was NOT added (file should not be modified)
if _, exists := config.Servers["github-agentic-workflows"]; exists {
t.Errorf("Expected 'github-agentic-workflows' server to NOT be added (should render instructions instead)")
}
}

func TestEnsureCopilotSetupSteps_InjectsStep(t *testing.T) {
func TestEnsureCopilotSetupSteps_RendersInstructions(t *testing.T) {
// Create a temporary directory for testing
tempDir := testutil.TempDir(t, "test-*")

Expand Down Expand Up @@ -268,34 +269,22 @@ jobs:
t.Fatalf("ensureCopilotSetupSteps() returned error: %v", err)
}

// Verify the extension install step was injected
// Verify the file was NOT modified (should render instructions instead)
content, err := os.ReadFile(setupStepsPath)
if err != nil {
t.Fatalf("Failed to read setup steps file: %v", err)
}

contentStr := string(content)
if !strings.Contains(contentStr, "Install gh-aw extension") {
t.Errorf("Expected extension install step to be injected")
}
if !strings.Contains(contentStr, "install-gh-aw.sh") {
t.Errorf("Expected extension install command to be present with bash script")
}
if !strings.Contains(contentStr, "curl -fsSL") {
t.Errorf("Expected curl command to be present")
}

// Verify it was injected at the beginning (before other steps)
extensionIndex := strings.Index(contentStr, "Install gh-aw extension")
someStepIndex := strings.Index(contentStr, "Some step")
buildIndex := strings.Index(contentStr, "Build code")

if extensionIndex == -1 || someStepIndex == -1 || buildIndex == -1 {
t.Fatalf("Could not find expected steps in file")
// File should remain unchanged
if contentStr != customContent {
t.Errorf("Expected file to remain unchanged (should render instructions instead of modifying)")
}

if extensionIndex >= someStepIndex || someStepIndex >= buildIndex {
t.Errorf("Extension install step not in correct position (should be at beginning, before other steps)")
// Verify extension install step was NOT injected
if strings.Contains(contentStr, "Install gh-aw extension") {
t.Errorf("Expected extension install step to NOT be injected (should render instructions instead)")
}
}

Expand Down
Loading
Loading