From 9f44c87acdc5a7bfe51eabbe7f6822fe6d2d0eb9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:38:53 +0000 Subject: [PATCH 01/11] Initial plan From 7b4147d5372a55cb7ae300056734ccae4f093e27 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:57:50 +0000 Subject: [PATCH 02/11] Make copilot setup workflows zizmor clean Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_setup.go | 300 +++++++++++++++++++++------------- pkg/cli/copilot_setup_test.go | 84 +++++++++- 2 files changed, 257 insertions(+), 127 deletions(-) diff --git a/pkg/cli/copilot_setup.go b/pkg/cli/copilot_setup.go index 00e1ca0cf1a..eb204c3ea18 100644 --- a/pkg/cli/copilot_setup.go +++ b/pkg/cli/copilot_setup.go @@ -15,6 +15,7 @@ import ( "github.com/goccy/go-yaml" + "github.com/github/gh-aw/pkg/actionpins" "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/fileutil" @@ -40,6 +41,10 @@ const copilotSetupStepsStaticSHA256 = "248ccebcb998c6a506548156e1bf9f02429cbbaec // sha256HexRegex matches a valid lowercase SHA256 hex digest (exactly 64 hex chars). var sha256HexRegex = regexp.MustCompile(`^[0-9a-f]{64}$`) +func latestCheckoutActionRef() string { + return actionpins.ResolveLatestActionPin("actions/checkout", nil) +} + // resolveInstallScriptSHA256 fetches install-gh-aw.sh at the given immutable commit SHA // and returns its SHA256 hex digest for use in a sha256sum integrity check. // Returns an empty string and logs a warning if the fetch or computation fails. @@ -99,15 +104,7 @@ func getActionRef(ctx context.Context, actionMode workflow.ActionMode, version s return "@main" } -// generateCopilotSetupStepsYAML generates the copilot-setup-steps.yml content based on action mode -func generateCopilotSetupStepsYAML(ctx context.Context, actionMode workflow.ActionMode, version string, resolver workflow.SHAResolver) string { - // Determine the action reference - use SHA-pinned or version tag in release/action mode, @main in dev mode - actionRef := getActionRef(ctx, actionMode, version, resolver) - - if actionMode.IsRelease() || actionMode.IsAction() { - // Determine the action repo based on mode - actionRepo := "github/gh-aw-actions/setup-cli" - return fmt.Sprintf(`name: "Copilot Setup Steps" +const copilotSetupActionTemplate = `name: "Copilot Setup Steps" # This workflow configures the environment for GitHub Copilot Agent with gh-aw MCP server on: @@ -116,9 +113,17 @@ on: paths: - .github/workflows/copilot-setup-steps.yml +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: # The job MUST be called 'copilot-setup-steps' to be recognized by GitHub Copilot Agent copilot-setup-steps: + name: Copilot Setup Steps runs-on: ubuntu-latest # Set minimal permissions for setup steps @@ -128,27 +133,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: %s + with: + persist-credentials: false - name: Install gh-aw extension uses: %s%s with: version: %s -`, actionRepo, actionRef, version) - } +` - // Default (dev/script mode): try to resolve the main branch to a pinned SHA so the - // downloaded script is immutable; fall back to the mutable branch ref if unavailable. - installRef := "refs/heads/main" - installSHA256 := "" - if sha, err := workflow.ResolveGhAwRef(ctx, "main"); err == nil && sha != "" { - installRef = sha - // Fetch the script to compute an explicit SHA256 integrity check line. - installSHA256 = resolveInstallScriptSHA256(ctx, sha) - } else { - copilotSetupLog.Printf("Could not resolve github/gh-aw main SHA for dev-mode template, falling back to mutable ref: %v", err) - } - sha256Cmd := sha256CheckLine(installSHA256, installScriptTempPath) - return fmt.Sprintf(`name: "Copilot Setup Steps" +const copilotSetupScriptTemplate = `name: "Copilot Setup Steps" # This workflow configures the environment for GitHub Copilot Agent with gh-aw MCP server on: @@ -157,9 +151,17 @@ on: paths: - .github/workflows/copilot-setup-steps.yml +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: # The job MUST be called 'copilot-setup-steps' to be recognized by GitHub Copilot Agent copilot-setup-steps: + name: Copilot Setup Steps runs-on: ubuntu-latest # Set minimal permissions for setup steps @@ -173,40 +175,46 @@ jobs: mkdir -p /tmp/gh-aw curl -fsSL https://raw.githubusercontent.com/github/gh-aw/%s/install-gh-aw.sh -o %s %s bash %s -`, installRef, installScriptTempPath, sha256Cmd, installScriptTempPath) +` + +// generateCopilotSetupStepsYAML generates the copilot-setup-steps.yml content based on action mode +func generateCopilotSetupStepsYAML(ctx context.Context, actionMode workflow.ActionMode, version string, resolver workflow.SHAResolver) string { + // Determine the action reference - use SHA-pinned or version tag in release/action mode, @main in dev mode + actionRef := getActionRef(ctx, actionMode, version, resolver) + checkoutRef := latestCheckoutActionRef() + + if actionMode.IsRelease() || actionMode.IsAction() { + // Determine the action repo based on mode + actionRepo := "github/gh-aw-actions/setup-cli" + return fmt.Sprintf(copilotSetupActionTemplate, checkoutRef, actionRepo, actionRef, version) + } + + // Default (dev/script mode): try to resolve the main branch to a pinned SHA so the + // downloaded script is immutable; fall back to the mutable branch ref if unavailable. + installRef := "refs/heads/main" + installSHA256 := "" + if sha, err := workflow.ResolveGhAwRef(ctx, "main"); err == nil && sha != "" { + installRef = sha + // Fetch the script to compute an explicit SHA256 integrity check line. + installSHA256 = resolveInstallScriptSHA256(ctx, sha) + } else { + copilotSetupLog.Printf("Could not resolve github/gh-aw main SHA for dev-mode template, falling back to mutable ref: %v", err) + } + sha256Cmd := sha256CheckLine(installSHA256, installScriptTempPath) + return fmt.Sprintf(copilotSetupScriptTemplate, installRef, installScriptTempPath, sha256Cmd, installScriptTempPath) } // copilotSetupStepsYAML is a static dev-mode template used only for YAML validity tests. // It is built from copilotSetupStepsStaticSHA and copilotSetupStepsStaticSHA256 so that // scripts/update-install-script-hashes.sh can refresh both values in a single place. // The runtime function generateCopilotSetupStepsYAML resolves the ref dynamically via ResolveGhAwRef. -var copilotSetupStepsYAML = fmt.Sprintf(`name: "Copilot Setup Steps" - -# This workflow configures the environment for GitHub Copilot Agent with gh-aw MCP server -on: - workflow_dispatch: - push: - paths: - - .github/workflows/copilot-setup-steps.yml - -jobs: - # The job MUST be called 'copilot-setup-steps' to be recognized by GitHub Copilot Agent - copilot-setup-steps: - runs-on: ubuntu-latest - - # Set minimal permissions for setup steps - # Copilot Agent receives its own token with appropriate permissions - permissions: - contents: read - - steps: - - name: Install gh-aw extension - run: | - mkdir -p /tmp/gh-aw - curl -fsSL https://raw.githubusercontent.com/github/gh-aw/%s/install-gh-aw.sh -o %s - echo "%s %s" | sha256sum -c - - bash %s -`, copilotSetupStepsStaticSHA, installScriptTempPath, copilotSetupStepsStaticSHA256, installScriptTempPath, installScriptTempPath) +var copilotSetupStepsYAML = fmt.Sprintf( + copilotSetupScriptTemplate, + copilotSetupStepsStaticSHA, + installScriptTempPath, + sha256CheckLine(copilotSetupStepsStaticSHA256, installScriptTempPath), + installScriptTempPath, +) // copilotSetupStepsJobName is the job name GitHub Copilot coding agent looks for in // .github/workflows/copilot-setup-steps.yml. When it is missing (or the workflow cannot @@ -385,81 +393,77 @@ func ensureCopilotSetupStepsWithUpgrade(ctx context.Context, verbose bool, actio // Check if file already exists if _, err := os.Stat(setupStepsPath); err == nil { - copilotSetupLog.Printf("File already exists: %s", setupStepsPath) + return handleExistingCopilotSetupSteps(ctx, verbose, actionMode, version, resolver, setupStepsPath, upgradeVersion) + } - // Read existing file to check if extension install step exists - content, err := os.ReadFile(setupStepsPath) - if err != nil { - return fmt.Errorf("failed to read existing copilot-setup-steps.yml: %w", err) - } + // File doesn't exist - create it + generated := generateCopilotSetupStepsYAML(ctx, actionMode, version, resolver) + if err := validateCopilotSetupStepsContent([]byte(generated)); err != nil { + return fmt.Errorf("generated copilot-setup-steps.yml is not valid: %w", err) + } + if err := os.WriteFile(setupStepsPath, []byte(generated), constants.FilePermSensitive); err != nil { + return fmt.Errorf("failed to write copilot-setup-steps.yml: %w", err) + } + copilotSetupLog.Printf("Created file: %s", setupStepsPath) - // Warn about an unusable existing file before any early return below - warnIfCopilotSetupStepsInvalid(setupStepsPath, content) + return nil +} - // Check if the extension install step is already present (check for both modes) - contentStr := string(content) - hasLegacyInstall := strings.Contains(contentStr, "install-gh-aw.sh") || - (strings.Contains(contentStr, "Install gh-aw extension") && strings.Contains(contentStr, "curl -fsSL")) - hasActionInstall := strings.Contains(contentStr, "actions/setup-cli") +func handleExistingCopilotSetupSteps(ctx context.Context, verbose bool, actionMode workflow.ActionMode, version string, resolver workflow.SHAResolver, setupStepsPath string, upgradeVersion bool) error { + copilotSetupLog.Printf("File already exists: %s", setupStepsPath) - // 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 (upgrade command)") + content, err := os.ReadFile(setupStepsPath) + if err != nil { + return fmt.Errorf("failed to read existing copilot-setup-steps.yml: %w", err) + } - upgraded, updatedContent, err := upgradeSetupCliVersionInContent(ctx, content, actionMode, version, resolver) - if err != nil { - return fmt.Errorf("failed to upgrade setup-cli version: %w", err) - } + warnIfCopilotSetupStepsInvalid(setupStepsPath, content) + contentStr := string(content) + hasLegacyInstall := strings.Contains(contentStr, "install-gh-aw.sh") || + (strings.Contains(contentStr, "Install gh-aw extension") && strings.Contains(contentStr, "curl -fsSL")) + hasActionInstall := strings.Contains(contentStr, "actions/setup-cli") - if !upgraded { - copilotSetupLog.Print("No version upgrade needed") - if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessageStderr("No version upgrade needed for "+setupStepsPath)) - } - return nil - } + if (hasLegacyInstall || hasActionInstall) && upgradeVersion { + return upgradeExistingCopilotSetupSteps(ctx, verbose, actionMode, version, resolver, setupStepsPath, content) + } - if err := validateCopilotSetupStepsContent(updatedContent); err != nil { - return fmt.Errorf("upgraded copilot-setup-steps.yml is not valid: %w", err) - } + if hasLegacyInstall || hasActionInstall { + copilotSetupLog.Print("Extension install step already exists, file is up to date") + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessageStderr(fmt.Sprintf("Skipping %s (already has gh-aw extension install step)", setupStepsPath))) + } + return nil + } - if err := os.WriteFile(setupStepsPath, updatedContent, constants.FilePermSensitive); err != nil { - return fmt.Errorf("failed to update copilot-setup-steps.yml: %w", err) - } - copilotSetupLog.Printf("Upgraded version in file: %s", setupStepsPath) + copilotSetupLog.Print("File exists without install step, rendering update instructions instead of editing") + renderCopilotSetupUpdateInstructions(ctx, setupStepsPath, actionMode, version, resolver) + return nil +} - if verbose { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessageStderr(fmt.Sprintf("Updated %s with new version %s", setupStepsPath, version))) - } - return nil - } +func upgradeExistingCopilotSetupSteps(ctx context.Context, verbose bool, actionMode workflow.ActionMode, version string, resolver workflow.SHAResolver, setupStepsPath string, content []byte) error { + copilotSetupLog.Print("Extension install step exists, attempting version upgrade (upgrade command)") - // File exists - render instructions instead of editing - if hasLegacyInstall || hasActionInstall { - copilotSetupLog.Print("Extension install step already exists, file is up to date") - if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessageStderr(fmt.Sprintf("Skipping %s (already has gh-aw extension install step)", setupStepsPath))) - } - return nil + upgraded, updatedContent, err := upgradeSetupCliVersionInContent(ctx, content, actionMode, version, resolver) + if err != nil { + return fmt.Errorf("failed to upgrade setup-cli version: %w", err) + } + if !upgraded { + copilotSetupLog.Print("No version upgrade needed") + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessageStderr("No version upgrade needed for "+setupStepsPath)) } - - // File exists but needs update - render instructions - copilotSetupLog.Print("File exists without install step, rendering update instructions instead of editing") - renderCopilotSetupUpdateInstructions(ctx, setupStepsPath, actionMode, version, resolver) return nil } - - // File doesn't exist - create it - generated := generateCopilotSetupStepsYAML(ctx, actionMode, version, resolver) - if err := validateCopilotSetupStepsContent([]byte(generated)); err != nil { - return fmt.Errorf("generated copilot-setup-steps.yml is not valid: %w", err) + if err := validateCopilotSetupStepsContent(updatedContent); err != nil { + return fmt.Errorf("upgraded copilot-setup-steps.yml is not valid: %w", err) } - if err := os.WriteFile(setupStepsPath, []byte(generated), constants.FilePermSensitive); err != nil { - return fmt.Errorf("failed to write copilot-setup-steps.yml: %w", err) + if err := os.WriteFile(setupStepsPath, updatedContent, constants.FilePermSensitive); err != nil { + return fmt.Errorf("failed to update copilot-setup-steps.yml: %w", err) + } + copilotSetupLog.Printf("Upgraded version in file: %s", setupStepsPath) + if verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessageStderr(fmt.Sprintf("Updated %s with new version %s", setupStepsPath, version))) } - copilotSetupLog.Printf("Created file: %s", setupStepsPath) - return nil } @@ -491,7 +495,9 @@ func renderCopilotSetupUpdateInstructions(ctx context.Context, filePath string, if actionMode.IsRelease() || actionMode.IsAction() { actionRepo := "github/gh-aw-actions/setup-cli" fmt.Fprintln(os.Stderr, " - name: Checkout repository") - fmt.Fprintln(os.Stderr, " uses: actions/checkout@v6") + fmt.Fprintln(os.Stderr, " uses: "+latestCheckoutActionRef()) + fmt.Fprintln(os.Stderr, " with:") + fmt.Fprintln(os.Stderr, " persist-credentials: false") fmt.Fprintln(os.Stderr, " - name: Install gh-aw extension") fmt.Fprintln(os.Stderr, " uses: "+actionRepo+actionRef) fmt.Fprintln(os.Stderr, " with:") @@ -524,6 +530,8 @@ func renderCopilotSetupUpdateInstructions(ctx context.Context, filePath string, var setupCliUsesPattern = regexp.MustCompile( `(?m)^(\s+uses:[ \t]*)"?(github/gh-aw(?:-actions)?/(?:actions/)?setup-cli@[^"\n]*)"?([ \t]*)$`) +var checkoutUsesLinePattern = regexp.MustCompile(`^([ \t]*)uses:[ \t]*"?actions/checkout@[^"\n]*"?[ \t]*(\r?\n?)$`) + // versionInWithPattern matches the version: parameter in the with: block that immediately // follows any setup-cli uses: line (any ref format: version tag, SHA-pinned, or quoted). // It is anchored to the same action repos as setupCliUsesPattern so that it only updates @@ -550,9 +558,10 @@ var setupCliUsesPattern = regexp.MustCompile( var versionInWithPattern = regexp.MustCompile( `(?s)([ \t]+uses:[ \t]*"?github/gh-aw(?:-actions)?/(?:actions/)?setup-cli@[^"\n]*"?[^\n]*\n(?:[^\n]*\n)*?[ \t]+with:[ \t]*\n(?:[^\n]*\n)*?[ \t]+version:[ \t]*)(\S+)([ \t]*(?:\n|$))`) -// upgradeSetupCliVersionInContent replaces the setup-cli action reference and the -// associated version: parameter in the raw YAML content using targeted regex -// substitutions, preserving all other formatting in the file. +// upgradeSetupCliVersionInContent replaces the setup-cli action reference, any +// actions/checkout reference, and the associated version: parameter in the raw +// YAML content using targeted regex substitutions, preserving all other +// formatting in the file. // // Returns (upgraded, updatedContent, error). upgraded is false when no change // was required (e.g. already at the target version, or file has no setup-cli step). @@ -571,6 +580,7 @@ func upgradeSetupCliVersionInContent(ctx context.Context, content []byte, action // Replace the uses: line, stripping any surrounding quotes in the process. updated := setupCliUsesPattern.ReplaceAll(content, []byte("${1}"+newUses+"${3}")) + updated, checkoutUpdated := pinCheckoutUsesInContent(updated) // Replace the version: value in the with: block immediately following the // setup-cli uses: line. versionInWithPattern matches any valid setup-cli @@ -578,8 +588,62 @@ func upgradeSetupCliVersionInContent(ctx context.Context, content []byte, action // the uses: comment and the version: parameter before the upgrade was run. updated = versionInWithPattern.ReplaceAll(updated, []byte("${1}"+version+"${3}")) - if bytes.Equal(content, updated) { + if !checkoutUpdated && bytes.Equal(content, updated) { return false, content, nil } return true, updated, nil } + +func pinCheckoutUsesInContent(content []byte) ([]byte, bool) { + lines := strings.SplitAfter(string(content), "\n") + changed := false + + for i := 0; i < len(lines); i++ { + matches := checkoutUsesLinePattern.FindStringSubmatch(lines[i]) + if matches == nil { + continue + } + + indent := matches[1] + newline := matches[2] + if newline == "" { + newline = "\n" + } + lines[i] = indent + "uses: " + latestCheckoutActionRef() + newline + changed = true + + if i+1 < len(lines) && strings.HasPrefix(lines[i+1], indent+"with:") { + if !checkoutWithBlockHasPersistCredentials(lines, i+2, indent) { + insert := indent + " persist-credentials: false" + newline + lines = slices.Insert(lines, i+2, insert) + } + continue + } + + lines = slices.Insert(lines, i+1, + indent+"with:"+newline, + indent+" persist-credentials: false"+newline, + ) + } + + if !changed { + return content, false + } + return []byte(strings.Join(lines, "")), true +} + +func checkoutWithBlockHasPersistCredentials(lines []string, start int, usesIndent string) bool { + for i := start; i < len(lines); i++ { + trimmed := strings.TrimSpace(lines[i]) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + if !strings.HasPrefix(lines[i], usesIndent+" ") { + return false + } + if strings.HasPrefix(trimmed, "persist-credentials:") { + return true + } + } + return false +} diff --git a/pkg/cli/copilot_setup_test.go b/pkg/cli/copilot_setup_test.go index 5b6369c61ee..3883bf9113a 100644 --- a/pkg/cli/copilot_setup_test.go +++ b/pkg/cli/copilot_setup_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/github/gh-aw/pkg/actionpins" "github.com/github/gh-aw/pkg/testutil" "github.com/github/gh-aw/pkg/workflow" @@ -525,9 +526,12 @@ func TestEnsureCopilotSetupSteps_ReleaseMode(t *testing.T) { t.Errorf("Expected copilot-setup-steps.yml to have version: v1.2.3, got:\n%s", contentStr) } - // Verify it has checkout step - if !strings.Contains(contentStr, "actions/checkout@v6") { - t.Error("Expected copilot-setup-steps.yml to have checkout step in release mode") + // Verify it has a pinned checkout step + if !strings.Contains(contentStr, "uses: "+actionpins.ResolveLatestActionPin("actions/checkout", nil)) { + t.Error("Expected copilot-setup-steps.yml to have pinned checkout step in release mode") + } + if !strings.Contains(contentStr, "persist-credentials: false") { + t.Error("Expected copilot-setup-steps.yml checkout to disable credential persistence") } // Verify it doesn't use curl/install-gh-aw.sh @@ -615,8 +619,11 @@ func TestEnsureCopilotSetupSteps_CreateWithReleaseMode(t *testing.T) { if !strings.Contains(contentStr, "version: v2.0.0") { t.Errorf("Expected version parameter v2.0.0, got:\n%s", contentStr) } - if !strings.Contains(contentStr, "actions/checkout@v6") { - t.Errorf("Expected checkout step in release mode") + if !strings.Contains(contentStr, "uses: "+actionpins.ResolveLatestActionPin("actions/checkout", nil)) { + t.Errorf("Expected pinned checkout step in release mode") + } + if !strings.Contains(contentStr, "persist-credentials: false") { + t.Error("Expected checkout step to disable credential persistence") } } @@ -1192,6 +1199,16 @@ jobs: resolver: nil, expectUpgrade: true, validate: func(t *testing.T, got string) { + wantCheckoutRef := "uses: " + actionpins.ResolveLatestActionPin("actions/checkout", nil) + if !strings.Contains(got, wantCheckoutRef) { + t.Errorf("Expected updated checkout uses: line %q, got:\n%s", wantCheckoutRef, got) + } + if strings.Contains(got, "uses: actions/checkout@v4") { + t.Errorf("Old checkout tag should be gone, got:\n%s", got) + } + if !strings.Contains(got, "persist-credentials: false") { + t.Errorf("Expected checkout to disable credential persistence, got:\n%s", got) + } if !strings.Contains(got, "uses: github/gh-aw-actions/setup-cli@v2.0.0") { t.Errorf("Expected updated uses: line, got:\n%s", got) } @@ -1415,7 +1432,9 @@ jobs: run: echo "hello" # inline run comment ` - // Expected output: identical to input except the two target lines. + checkoutRef := actionpins.ResolveLatestActionPin("actions/checkout", nil) + + // Expected output: identical to input except the setup-cli, checkout, and version lines. expected := `# Top-level workflow comment — must survive the upgrade. name: "Copilot Setup Steps" @@ -1437,8 +1456,9 @@ jobs: steps: # Step 1 comment. - name: Checkout repository - uses: actions/checkout@v4 # pin to stable tag + uses: ` + checkoutRef + ` with: + persist-credentials: false fetch-depth: 0 # full history # Step 2 comment — this step should be updated. @@ -1468,7 +1488,7 @@ jobs: expectedLines := strings.Split(expected, "\n") gotLines := strings.Split(gotStr, "\n") - t.Errorf("Output does not match expected (only uses: and version: lines should differ).\n") + t.Errorf("Output does not match expected (only checkout/setup-cli uses: and version: lines should differ).\n") for i := 0; i < len(expectedLines) || i < len(gotLines); i++ { var exp, act string if i < len(expectedLines) { @@ -1570,12 +1590,21 @@ jobs: `- .github/workflows/copilot-setup-steps.yml`, `permissions:`, `contents: read`, - `uses: actions/checkout@v4`, } { if !strings.Contains(updatedStr, preserved) { t.Errorf("Expected content %q to be preserved, got:\n%s", preserved, updatedStr) } } + wantCheckoutRef := "uses: " + actionpins.ResolveLatestActionPin("actions/checkout", nil) + if !strings.Contains(updatedStr, wantCheckoutRef) { + t.Errorf("Expected checkout uses: line %q, got:\n%s", wantCheckoutRef, updatedStr) + } + if strings.Contains(updatedStr, "uses: actions/checkout@v4") { + t.Errorf("Old checkout tag should be gone, got:\n%s", updatedStr) + } + if !strings.Contains(updatedStr, "persist-credentials: false") { + t.Errorf("Expected checkout to disable credential persistence, got:\n%s", updatedStr) + } } // TestGetActionRef tests the getActionRef helper with and without a resolver @@ -1878,6 +1907,43 @@ func TestGeneratedCopilotSetupStepsIsValid(t *testing.T) { } } +func TestGeneratedCopilotSetupStepsPinsCheckout(t *testing.T) { + t.Parallel() + + expectedCheckoutRef := actionpins.ResolveLatestActionPin("actions/checkout", nil) + if expectedCheckoutRef == "" { + t.Fatal("expected embedded actions/checkout pin") + } + + modes := []workflow.ActionMode{ + workflow.ActionModeRelease, + workflow.ActionModeAction, + } + + for _, mode := range modes { + t.Run(string(mode), func(t *testing.T) { + t.Parallel() + content := generateCopilotSetupStepsYAML(context.Background(), mode, "v1.2.3", &mockSHAResolver{sha: "bd9c0ca491e6334a2797ef56ad6ee89958d54ab9"}) + if strings.Contains(content, "uses: actions/checkout@v6") { + t.Fatalf("generated copilot setup steps must not use unpinned checkout tag:\n%s", content) + } + if !strings.Contains(content, "uses: "+expectedCheckoutRef) { + t.Fatalf("generated copilot setup steps should use pinned checkout ref %q:\n%s", expectedCheckoutRef, content) + } + for _, want := range []string{ + "permissions:\n contents: read", + "concurrency:\n group: ${{ github.workflow }}-${{ github.ref }}\n cancel-in-progress: true", + "name: Copilot Setup Steps", + "persist-credentials: false", + } { + if !strings.Contains(content, want) { + t.Fatalf("generated copilot setup steps should contain %q for Zizmor-clean output:\n%s", want, content) + } + } + }) + } +} + func TestEnsureCopilotSetupStepsWritesValidWorkflow(t *testing.T) { tmpDir := testutil.TempDir(t, "copilot-setup-valid-*") t.Setenv("GH_AW_WORKFLOWS_DIR", filepath.Join(tmpDir, ".github", "workflows")) From 1af8e6fabd084dcc63875ee8fd58d30666e14676 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:00:12 +0000 Subject: [PATCH 03/11] Address copilot setup review feedback Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_setup.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/cli/copilot_setup.go b/pkg/cli/copilot_setup.go index eb204c3ea18..906bfc71717 100644 --- a/pkg/cli/copilot_setup.go +++ b/pkg/cli/copilot_setup.go @@ -596,6 +596,7 @@ func upgradeSetupCliVersionInContent(ctx context.Context, content []byte, action func pinCheckoutUsesInContent(content []byte) ([]byte, bool) { lines := strings.SplitAfter(string(content), "\n") + checkoutRef := latestCheckoutActionRef() changed := false for i := 0; i < len(lines); i++ { @@ -609,7 +610,7 @@ func pinCheckoutUsesInContent(content []byte) ([]byte, bool) { if newline == "" { newline = "\n" } - lines[i] = indent + "uses: " + latestCheckoutActionRef() + newline + lines[i] = indent + "uses: " + checkoutRef + newline changed = true if i+1 < len(lines) && strings.HasPrefix(lines[i+1], indent+"with:") { From 789ade5f6375aa182f70d278f5fbc6e2f452fd24 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:02:36 +0000 Subject: [PATCH 04/11] Clarify checkout upgrade loop Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_setup.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/cli/copilot_setup.go b/pkg/cli/copilot_setup.go index 906bfc71717..64dc34907fa 100644 --- a/pkg/cli/copilot_setup.go +++ b/pkg/cli/copilot_setup.go @@ -618,6 +618,7 @@ func pinCheckoutUsesInContent(content []byte) ([]byte, bool) { insert := indent + " persist-credentials: false" + newline lines = slices.Insert(lines, i+2, insert) } + i++ continue } @@ -625,6 +626,7 @@ func pinCheckoutUsesInContent(content []byte) ([]byte, bool) { indent+"with:"+newline, indent+" persist-credentials: false"+newline, ) + i += 2 } if !changed { From a324c2c098ddbe082160758abdc98c55011df7da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:05:17 +0000 Subject: [PATCH 05/11] Harden checkout upgrade block Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_setup.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/cli/copilot_setup.go b/pkg/cli/copilot_setup.go index 64dc34907fa..8f7a33429f7 100644 --- a/pkg/cli/copilot_setup.go +++ b/pkg/cli/copilot_setup.go @@ -618,7 +618,7 @@ func pinCheckoutUsesInContent(content []byte) ([]byte, bool) { insert := indent + " persist-credentials: false" + newline lines = slices.Insert(lines, i+2, insert) } - i++ + i = checkoutWithBlockEnd(lines, i+2, indent) - 1 continue } @@ -635,15 +635,22 @@ func pinCheckoutUsesInContent(content []byte) ([]byte, bool) { return []byte(strings.Join(lines, "")), true } -func checkoutWithBlockHasPersistCredentials(lines []string, start int, usesIndent string) bool { +func checkoutWithBlockEnd(lines []string, start int, usesIndent string) int { for i := start; i < len(lines); i++ { trimmed := strings.TrimSpace(lines[i]) if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } if !strings.HasPrefix(lines[i], usesIndent+" ") { - return false + return i } + } + return len(lines) +} + +func checkoutWithBlockHasPersistCredentials(lines []string, start int, usesIndent string) bool { + for i := start; i < checkoutWithBlockEnd(lines, start, usesIndent); i++ { + trimmed := strings.TrimSpace(lines[i]) if strings.HasPrefix(trimmed, "persist-credentials:") { return true } From 7d83871edfd7607bdba1990744952ae6cb02ee9b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:09:59 +0000 Subject: [PATCH 06/11] Handle checkout pinning edge cases Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_setup.go | 14 +++++++----- pkg/cli/copilot_setup_test.go | 43 +++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/pkg/cli/copilot_setup.go b/pkg/cli/copilot_setup.go index 8f7a33429f7..c19916c4a18 100644 --- a/pkg/cli/copilot_setup.go +++ b/pkg/cli/copilot_setup.go @@ -595,7 +595,9 @@ func upgradeSetupCliVersionInContent(ctx context.Context, content []byte, action } func pinCheckoutUsesInContent(content []byte) ([]byte, bool) { - lines := strings.SplitAfter(string(content), "\n") + contentStr := string(content) + hadTrailingNewline := strings.HasSuffix(contentStr, "\n") + lines := strings.SplitAfter(contentStr, "\n") checkoutRef := latestCheckoutActionRef() changed := false @@ -632,15 +634,15 @@ func pinCheckoutUsesInContent(content []byte) ([]byte, bool) { if !changed { return content, false } - return []byte(strings.Join(lines, "")), true + result := strings.Join(lines, "") + if !hadTrailingNewline { + result = strings.TrimSuffix(result, "\n") + } + return []byte(result), true } func checkoutWithBlockEnd(lines []string, start int, usesIndent string) int { for i := start; i < len(lines); i++ { - trimmed := strings.TrimSpace(lines[i]) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } if !strings.HasPrefix(lines[i], usesIndent+" ") { return i } diff --git a/pkg/cli/copilot_setup_test.go b/pkg/cli/copilot_setup_test.go index 3883bf9113a..e6482d7acb9 100644 --- a/pkg/cli/copilot_setup_test.go +++ b/pkg/cli/copilot_setup_test.go @@ -1607,6 +1607,49 @@ jobs: } } +func TestPinCheckoutUsesInContent(t *testing.T) { + t.Parallel() + + checkoutRef := actionpins.ResolveLatestActionPin("actions/checkout", nil) + + t.Run("preserves missing trailing newline", func(t *testing.T) { + t.Parallel() + input := " uses: actions/checkout@v4" + got, changed := pinCheckoutUsesInContent([]byte(input)) + if !changed { + t.Fatal("expected checkout line to be updated") + } + expected := " uses: " + checkoutRef + "\n with:\n persist-credentials: false" + if string(got) != expected { + t.Fatalf("expected %q, got %q", expected, string(got)) + } + }) + + t.Run("blank line ends existing with block", func(t *testing.T) { + t.Parallel() + input := ` - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Other step + with: + persist-credentials: true +` + got, changed := pinCheckoutUsesInContent([]byte(input)) + if !changed { + t.Fatal("expected checkout line to be updated") + } + gotStr := string(got) + if !strings.Contains(gotStr, " uses: "+checkoutRef) { + t.Fatalf("expected checkout to use %q, got:\n%s", checkoutRef, gotStr) + } + if !strings.Contains(gotStr, " persist-credentials: false\n fetch-depth: 0") { + t.Fatalf("expected persist-credentials in checkout with block, got:\n%s", gotStr) + } + }) +} + // TestGetActionRef tests the getActionRef helper with and without a resolver func TestGetActionRef(t *testing.T) { tests := []struct { From b2b5b8b22a18f4c0286520198241d74cfd962ac9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:12:37 +0000 Subject: [PATCH 07/11] Clarify checkout with-block pinning Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_setup.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pkg/cli/copilot_setup.go b/pkg/cli/copilot_setup.go index c19916c4a18..00c29b167d4 100644 --- a/pkg/cli/copilot_setup.go +++ b/pkg/cli/copilot_setup.go @@ -616,11 +616,13 @@ func pinCheckoutUsesInContent(content []byte) ([]byte, bool) { changed = true if i+1 < len(lines) && strings.HasPrefix(lines[i+1], indent+"with:") { - if !checkoutWithBlockHasPersistCredentials(lines, i+2, indent) { + blockEnd := checkoutWithBlockEnd(lines, i+2, indent) + if !checkoutWithBlockHasPersistCredentials(lines, i+2, blockEnd) { insert := indent + " persist-credentials: false" + newline lines = slices.Insert(lines, i+2, insert) + blockEnd++ } - i = checkoutWithBlockEnd(lines, i+2, indent) - 1 + i = blockEnd - 1 continue } @@ -650,8 +652,8 @@ func checkoutWithBlockEnd(lines []string, start int, usesIndent string) int { return len(lines) } -func checkoutWithBlockHasPersistCredentials(lines []string, start int, usesIndent string) bool { - for i := start; i < checkoutWithBlockEnd(lines, start, usesIndent); i++ { +func checkoutWithBlockHasPersistCredentials(lines []string, start int, end int) bool { + for i := start; i < end; i++ { trimmed := strings.TrimSpace(lines[i]) if strings.HasPrefix(trimmed, "persist-credentials:") { return true From 4a9f6de58341fad634050514b5e390a49a22d92b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:15:11 +0000 Subject: [PATCH 08/11] Preserve checkout upgrade line endings Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_setup.go | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/pkg/cli/copilot_setup.go b/pkg/cli/copilot_setup.go index 00c29b167d4..41f0578937e 100644 --- a/pkg/cli/copilot_setup.go +++ b/pkg/cli/copilot_setup.go @@ -530,7 +530,7 @@ func renderCopilotSetupUpdateInstructions(ctx context.Context, filePath string, var setupCliUsesPattern = regexp.MustCompile( `(?m)^(\s+uses:[ \t]*)"?(github/gh-aw(?:-actions)?/(?:actions/)?setup-cli@[^"\n]*)"?([ \t]*)$`) -var checkoutUsesLinePattern = regexp.MustCompile(`^([ \t]*)uses:[ \t]*"?actions/checkout@[^"\n]*"?[ \t]*(\r?\n?)$`) +var checkoutUsesLinePattern = regexp.MustCompile(`^([ \t]*)uses:[ \t]*"?actions/checkout@[^"\n]*"?[ \t]*\r?$`) // versionInWithPattern matches the version: parameter in the with: block that immediately // follows any setup-cli uses: line (any ref format: version tag, SHA-pinned, or quoted). @@ -597,7 +597,7 @@ func upgradeSetupCliVersionInContent(ctx context.Context, content []byte, action func pinCheckoutUsesInContent(content []byte) ([]byte, bool) { contentStr := string(content) hadTrailingNewline := strings.HasSuffix(contentStr, "\n") - lines := strings.SplitAfter(contentStr, "\n") + lines := strings.Split(strings.TrimSuffix(contentStr, "\n"), "\n") checkoutRef := latestCheckoutActionRef() changed := false @@ -608,17 +608,13 @@ func pinCheckoutUsesInContent(content []byte) ([]byte, bool) { } indent := matches[1] - newline := matches[2] - if newline == "" { - newline = "\n" - } - lines[i] = indent + "uses: " + checkoutRef + newline + lines[i] = indent + "uses: " + checkoutRef changed = true if i+1 < len(lines) && strings.HasPrefix(lines[i+1], indent+"with:") { blockEnd := checkoutWithBlockEnd(lines, i+2, indent) if !checkoutWithBlockHasPersistCredentials(lines, i+2, blockEnd) { - insert := indent + " persist-credentials: false" + newline + insert := indent + " persist-credentials: false" lines = slices.Insert(lines, i+2, insert) blockEnd++ } @@ -627,8 +623,8 @@ func pinCheckoutUsesInContent(content []byte) ([]byte, bool) { } lines = slices.Insert(lines, i+1, - indent+"with:"+newline, - indent+" persist-credentials: false"+newline, + indent+"with:", + indent+" persist-credentials: false", ) i += 2 } @@ -636,9 +632,9 @@ func pinCheckoutUsesInContent(content []byte) ([]byte, bool) { if !changed { return content, false } - result := strings.Join(lines, "") - if !hadTrailingNewline { - result = strings.TrimSuffix(result, "\n") + result := strings.Join(lines, "\n") + if hadTrailingNewline { + result += "\n" } return []byte(result), true } From b50b457d375509e15ae23ec8a59872d5f10f1145 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:18:00 +0000 Subject: [PATCH 09/11] Handle checkout with block blanks Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_setup.go | 16 ++++++++++++++++ pkg/cli/copilot_setup_test.go | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/pkg/cli/copilot_setup.go b/pkg/cli/copilot_setup.go index 41f0578937e..6761096a459 100644 --- a/pkg/cli/copilot_setup.go +++ b/pkg/cli/copilot_setup.go @@ -641,6 +641,12 @@ func pinCheckoutUsesInContent(content []byte) ([]byte, bool) { func checkoutWithBlockEnd(lines []string, start int, usesIndent string) int { for i := start; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "" { + if checkoutNextNonBlankLineInBlock(lines, i+1, usesIndent) { + continue + } + return i + } if !strings.HasPrefix(lines[i], usesIndent+" ") { return i } @@ -648,6 +654,16 @@ func checkoutWithBlockEnd(lines []string, start int, usesIndent string) int { return len(lines) } +func checkoutNextNonBlankLineInBlock(lines []string, start int, usesIndent string) bool { + for i := start; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "" { + continue + } + return strings.HasPrefix(lines[i], usesIndent+" ") + } + return false +} + func checkoutWithBlockHasPersistCredentials(lines []string, start int, end int) bool { for i := start; i < end; i++ { trimmed := strings.TrimSpace(lines[i]) diff --git a/pkg/cli/copilot_setup_test.go b/pkg/cli/copilot_setup_test.go index e6482d7acb9..c3da697f427 100644 --- a/pkg/cli/copilot_setup_test.go +++ b/pkg/cli/copilot_setup_test.go @@ -1648,6 +1648,25 @@ func TestPinCheckoutUsesInContent(t *testing.T) { t.Fatalf("expected persist-credentials in checkout with block, got:\n%s", gotStr) } }) + + t.Run("blank line inside existing with block", func(t *testing.T) { + t.Parallel() + input := ` - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + clean: false +` + got, changed := pinCheckoutUsesInContent([]byte(input)) + if !changed { + t.Fatal("expected checkout line to be updated") + } + gotStr := string(got) + if !strings.Contains(gotStr, " persist-credentials: false\n fetch-depth: 0\n\n clean: false") { + t.Fatalf("expected persist-credentials at the top of checkout with block, got:\n%s", gotStr) + } + }) } // TestGetActionRef tests the getActionRef helper with and without a resolver From bfeed0029c546cfbf6066411197e07041a0c88f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:34:05 +0000 Subject: [PATCH 10/11] Resolve setup default branch via API Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_setup.go | 28 ++++++++--- pkg/cli/copilot_setup_test.go | 92 +++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/pkg/cli/copilot_setup.go b/pkg/cli/copilot_setup.go index 6761096a459..e9303e425eb 100644 --- a/pkg/cli/copilot_setup.go +++ b/pkg/cli/copilot_setup.go @@ -41,6 +41,12 @@ const copilotSetupStepsStaticSHA256 = "248ccebcb998c6a506548156e1bf9f02429cbbaec // sha256HexRegex matches a valid lowercase SHA256 hex digest (exactly 64 hex chars). var sha256HexRegex = regexp.MustCompile(`^[0-9a-f]{64}$`) +var ( + resolveGhAwDefaultBranchForCopilotSetup = getRepoDefaultBranchCached + resolveGhAwRefForCopilotSetup = workflow.ResolveGhAwRef + resolveInstallScriptSHA256ForCopilotSetup = resolveInstallScriptSHA256 +) + func latestCheckoutActionRef() string { return actionpins.ResolveLatestActionPin("actions/checkout", nil) } @@ -189,16 +195,23 @@ func generateCopilotSetupStepsYAML(ctx context.Context, actionMode workflow.Acti return fmt.Sprintf(copilotSetupActionTemplate, checkoutRef, actionRepo, actionRef, version) } - // Default (dev/script mode): try to resolve the main branch to a pinned SHA so the - // downloaded script is immutable; fall back to the mutable branch ref if unavailable. - installRef := "refs/heads/main" + // Default (dev/script mode): resolve the repository default branch via the + // GitHub API, then pin it to a SHA so the downloaded script is immutable. + // Fall back to the mutable branch ref if unavailable. + defaultBranch := "main" + if branch, err := resolveGhAwDefaultBranchForCopilotSetup(ctx, "github/gh-aw"); err == nil && strings.TrimSpace(branch) != "" { + defaultBranch = strings.TrimSpace(branch) + } else { + copilotSetupLog.Printf("Could not resolve github/gh-aw default branch for dev-mode template, falling back to %q: %v", defaultBranch, err) + } + installRef := "refs/heads/" + defaultBranch installSHA256 := "" - if sha, err := workflow.ResolveGhAwRef(ctx, "main"); err == nil && sha != "" { + if sha, err := resolveGhAwRefForCopilotSetup(ctx, defaultBranch); err == nil && sha != "" { installRef = sha // Fetch the script to compute an explicit SHA256 integrity check line. - installSHA256 = resolveInstallScriptSHA256(ctx, sha) + installSHA256 = resolveInstallScriptSHA256ForCopilotSetup(ctx, sha) } else { - copilotSetupLog.Printf("Could not resolve github/gh-aw main SHA for dev-mode template, falling back to mutable ref: %v", err) + copilotSetupLog.Printf("Could not resolve github/gh-aw %s SHA for dev-mode template, falling back to mutable ref: %v", defaultBranch, err) } sha256Cmd := sha256CheckLine(installSHA256, installScriptTempPath) return fmt.Sprintf(copilotSetupScriptTemplate, installRef, installScriptTempPath, sha256Cmd, installScriptTempPath) @@ -207,7 +220,8 @@ func generateCopilotSetupStepsYAML(ctx context.Context, actionMode workflow.Acti // copilotSetupStepsYAML is a static dev-mode template used only for YAML validity tests. // It is built from copilotSetupStepsStaticSHA and copilotSetupStepsStaticSHA256 so that // scripts/update-install-script-hashes.sh can refresh both values in a single place. -// The runtime function generateCopilotSetupStepsYAML resolves the ref dynamically via ResolveGhAwRef. +// The runtime function generateCopilotSetupStepsYAML resolves the ref dynamically +// from the repository default branch via the GitHub API. var copilotSetupStepsYAML = fmt.Sprintf( copilotSetupScriptTemplate, copilotSetupStepsStaticSHA, diff --git a/pkg/cli/copilot_setup_test.go b/pkg/cli/copilot_setup_test.go index c3da697f427..309433a18b7 100644 --- a/pkg/cli/copilot_setup_test.go +++ b/pkg/cli/copilot_setup_test.go @@ -678,6 +678,98 @@ func TestEnsureCopilotSetupSteps_CreateWithDevMode(t *testing.T) { } } +func TestGenerateCopilotSetupStepsYAMLDevModeUsesDefaultBranchFromGitHubAPI(t *testing.T) { + const ( + defaultBranch = "stable" + resolvedSHA = "1111111111111111111111111111111111111111" + sha256Digest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ) + + defaultBranchCalled := false + resolveRefCalled := false + originalDefaultBranch := resolveGhAwDefaultBranchForCopilotSetup + originalResolveRef := resolveGhAwRefForCopilotSetup + originalSHA256 := resolveInstallScriptSHA256ForCopilotSetup + resolveGhAwDefaultBranchForCopilotSetup = func(_ context.Context, repo string) (string, error) { + defaultBranchCalled = true + if repo != "github/gh-aw" { + t.Fatalf("repo = %q, want github/gh-aw", repo) + } + return defaultBranch, nil + } + resolveGhAwRefForCopilotSetup = func(_ context.Context, ref string) (string, error) { + resolveRefCalled = true + if ref != defaultBranch { + t.Fatalf("ref = %q, want %q", ref, defaultBranch) + } + return resolvedSHA, nil + } + resolveInstallScriptSHA256ForCopilotSetup = func(_ context.Context, commitSHA string) string { + if commitSHA != resolvedSHA { + t.Fatalf("commitSHA = %q, want %q", commitSHA, resolvedSHA) + } + return sha256Digest + } + t.Cleanup(func() { + resolveGhAwDefaultBranchForCopilotSetup = originalDefaultBranch + resolveGhAwRefForCopilotSetup = originalResolveRef + resolveInstallScriptSHA256ForCopilotSetup = originalSHA256 + }) + + content := generateCopilotSetupStepsYAML(context.Background(), workflow.ActionModeDev, "dev", nil) + + if !defaultBranchCalled { + t.Fatal("expected default branch resolver to be called") + } + if !resolveRefCalled { + t.Fatal("expected default branch ref to be resolved") + } + if !strings.Contains(content, "https://raw.githubusercontent.com/github/gh-aw/"+resolvedSHA+"/install-gh-aw.sh") { + t.Fatalf("expected install script URL to use resolved SHA %q, got:\n%s", resolvedSHA, content) + } + if strings.Contains(content, "refs/heads/main") { + t.Fatalf("expected generated content not to hard-code refs/heads/main, got:\n%s", content) + } + if !strings.Contains(content, sha256Digest+" "+installScriptTempPath) { + t.Fatalf("expected generated content to include SHA256 integrity check, got:\n%s", content) + } +} + +func TestGenerateCopilotSetupStepsYAMLDevModeFallsBackToDefaultBranchRef(t *testing.T) { + const defaultBranch = "stable" + + originalDefaultBranch := resolveGhAwDefaultBranchForCopilotSetup + originalResolveRef := resolveGhAwRefForCopilotSetup + originalSHA256 := resolveInstallScriptSHA256ForCopilotSetup + resolveGhAwDefaultBranchForCopilotSetup = func(_ context.Context, _ string) (string, error) { + return defaultBranch, nil + } + resolveGhAwRefForCopilotSetup = func(_ context.Context, ref string) (string, error) { + if ref != defaultBranch { + t.Fatalf("ref = %q, want %q", ref, defaultBranch) + } + return "", errors.New("resolution failed") + } + resolveInstallScriptSHA256ForCopilotSetup = func(context.Context, string) string { + t.Fatal("SHA256 resolver should not be called when ref resolution fails") + return "" + } + t.Cleanup(func() { + resolveGhAwDefaultBranchForCopilotSetup = originalDefaultBranch + resolveGhAwRefForCopilotSetup = originalResolveRef + resolveInstallScriptSHA256ForCopilotSetup = originalSHA256 + }) + + content := generateCopilotSetupStepsYAML(context.Background(), workflow.ActionModeDev, "dev", nil) + + if !strings.Contains(content, "https://raw.githubusercontent.com/github/gh-aw/refs/heads/"+defaultBranch+"/install-gh-aw.sh") { + t.Fatalf("expected install script URL to fall back to default branch ref, got:\n%s", content) + } + if strings.Contains(content, "sha256sum -c -") { + t.Fatalf("did not expect SHA256 integrity check without resolved SHA, got:\n%s", content) + } +} + func TestEnsureCopilotSetupSteps_UsesWorkflowDirEnvOverride(t *testing.T) { tmpDir := t.TempDir() originalDir, err := os.Getwd() From 7a1410b3d8ddf43d8da46fc2469a800d81ccf7f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:39:17 +0000 Subject: [PATCH 11/11] Clarify default branch fallback logging Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/copilot_setup.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/cli/copilot_setup.go b/pkg/cli/copilot_setup.go index e9303e425eb..20d9a899065 100644 --- a/pkg/cli/copilot_setup.go +++ b/pkg/cli/copilot_setup.go @@ -199,10 +199,12 @@ func generateCopilotSetupStepsYAML(ctx context.Context, actionMode workflow.Acti // GitHub API, then pin it to a SHA so the downloaded script is immutable. // Fall back to the mutable branch ref if unavailable. defaultBranch := "main" - if branch, err := resolveGhAwDefaultBranchForCopilotSetup(ctx, "github/gh-aw"); err == nil && strings.TrimSpace(branch) != "" { - defaultBranch = strings.TrimSpace(branch) - } else { + if branch, err := resolveGhAwDefaultBranchForCopilotSetup(ctx, "github/gh-aw"); err != nil { copilotSetupLog.Printf("Could not resolve github/gh-aw default branch for dev-mode template, falling back to %q: %v", defaultBranch, err) + } else if branch = strings.TrimSpace(branch); branch != "" { + defaultBranch = branch + } else { + copilotSetupLog.Printf("Could not resolve github/gh-aw default branch for dev-mode template: empty branch returned, falling back to %q", defaultBranch) } installRef := "refs/heads/" + defaultBranch installSHA256 := ""