From 578b9cc5d31459335e8258f0623c18004052e766 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:49:44 +0000 Subject: [PATCH 1/6] Initial plan From eb4f0c7ec41b52d382de024190070661df6134a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:56:31 +0000 Subject: [PATCH 2/6] initial plan Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/smoke-macos-arm64.lock.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/smoke-macos-arm64.lock.yml b/.github/workflows/smoke-macos-arm64.lock.yml index 0c7cc2494b..8b605c64f1 100644 --- a/.github/workflows/smoke-macos-arm64.lock.yml +++ b/.github/workflows/smoke-macos-arm64.lock.yml @@ -431,8 +431,6 @@ jobs: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - name: Install GitHub Copilot CLI run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.411 - - name: Install Docker on macOS - run: bash /opt/gh-aw/actions/install_docker_macos.sh - name: Install awf binary run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.20.2 - name: Determine automatic lockdown mode for GitHub MCP Server From 9aa590c95c996efb7329816c4a4043ec6c245878 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 00:08:22 +0000 Subject: [PATCH 3/6] Add top-level checkout frontmatter field with CheckoutManager - Add CheckoutConfig struct with all actions/checkout fields - Add Checkout/CheckoutTyped fields to FrontmatterConfig - Add parseCheckoutConfig helper (single object or array) - Create CheckoutManager to encapsulate checkout step generation - Add CustomCheckouts field to WorkflowData - Extract checkout config in extractYAMLSections - Use CheckoutManager in generateMainJobSteps - Add checkout to JSON schema - Add 13 unit tests and 4 integration tests Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schemas/main_workflow_schema.json | 200 ++++++++++++- pkg/workflow/checkout_manager.go | 265 ++++++++++++++++++ pkg/workflow/checkout_manager_test.go | 199 +++++++++++++ .../compiler_orchestrator_workflow.go | 11 + pkg/workflow/compiler_types.go | 1 + pkg/workflow/compiler_yaml_main_job.go | 26 +- pkg/workflow/frontmatter_checkout_test.go | 181 ++++++++++++ pkg/workflow/frontmatter_types.go | 72 +++++ 8 files changed, 937 insertions(+), 18 deletions(-) create mode 100644 pkg/workflow/checkout_manager.go create mode 100644 pkg/workflow/checkout_manager_test.go create mode 100644 pkg/workflow/frontmatter_checkout_test.go diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 86ead0ca20..402ab1c454 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1381,12 +1381,12 @@ "description": "Skip workflow execution for specific GitHub users. Useful for preventing workflows from running for specific accounts (e.g., bots, specific team members)." }, "roles": { - "description": "Repository access roles required to trigger agentic workflows. Defaults to ['admin', 'maintainer', 'write'] for security. Use 'all' to allow any authenticated user (⚠️ security consideration).", + "description": "Repository access roles required to trigger agentic workflows. Defaults to ['admin', 'maintainer', 'write'] for security. Use 'all' to allow any authenticated user (\u26a0\ufe0f security consideration).", "oneOf": [ { "type": "string", "enum": ["all"], - "description": "Allow any authenticated user to trigger the workflow (⚠️ disables permission checking entirely - use with caution)" + "description": "Allow any authenticated user to trigger the workflow (\u26a0\ufe0f disables permission checking entirely - use with caution)" }, { "type": "array", @@ -6747,6 +6747,202 @@ } }, "additionalProperties": false + }, + "checkout": { + "description": "Custom checkout configuration. Accepts the same fields as actions/checkout. Can be a single object (overrides the main repository checkout) or an array of objects (multiple checkouts, each placed in its own subfolder).", + "examples": [ + { + "ref": "my-branch" + }, + { + "ref": "my-branch", + "token": "${{ secrets.MY_TOKEN }}", + "fetch-depth": 0 + }, + [ + { + "ref": "my-branch" + }, + { + "repository": "org/other-repo", + "ref": "main", + "path": "other-repo" + } + ] + ], + "oneOf": [ + { + "type": "object", + "description": "Configuration for a single actions/checkout step.", + "properties": { + "repository": { + "type": "string", + "description": "Repository to check out. Defaults to the current repository." + }, + "ref": { + "type": "string", + "description": "The branch, tag or SHA to check out." + }, + "token": { + "type": "string", + "description": "Personal access token or app installation token used to clone the repository." + }, + "ssh-key": { + "type": "string", + "description": "SSH key used to fetch the repository." + }, + "path": { + "type": "string", + "description": "Relative path under GITHUB_WORKSPACE to place the repository." + }, + "persist-credentials": { + "type": "boolean", + "description": "Whether to configure the token or SSH key with the local git config. Default: false." + }, + "clean": { + "type": "boolean", + "description": "Whether to execute git clean -ffdx and git reset --hard HEAD before fetching." + }, + "filter": { + "type": "string", + "description": "Partially clone the repository using a partial clone filter." + }, + "sparse-checkout": { + "type": "string", + "description": "Newline-separated list of patterns used for sparse-checkout." + }, + "sparse-checkout-cone-mode": { + "type": "boolean", + "description": "Whether to use cone-mode for sparse-checkout." + }, + "fetch-depth": { + "type": "integer", + "description": "Number of commits to fetch. 0 indicates all history." + }, + "fetch-tags": { + "type": "boolean", + "description": "Whether to fetch tags, even if fetch-depth > 0." + }, + "show-progress": { + "type": "boolean", + "description": "Whether to show progress status output when fetching." + }, + "lfs": { + "type": "boolean", + "description": "Whether to download Git-LFS files." + }, + "submodules": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": ["true", "false", "recursive"] + } + ], + "description": "Whether to checkout submodules." + }, + "set-safe-directory": { + "type": "boolean", + "description": "Whether to add the workspace to the safe.directory git config." + }, + "github-server-url": { + "type": "string", + "description": "The base URL for the GitHub instance that you are trying to clone from." + } + }, + "additionalProperties": false + }, + { + "type": "array", + "description": "Multiple checkout configurations. Each checkout is placed in its own subfolder (use the 'path' field to specify the folder).", + "items": { + "type": "object", + "description": "Configuration for a single actions/checkout step.", + "properties": { + "repository": { + "type": "string", + "description": "Repository to check out. Defaults to the current repository." + }, + "ref": { + "type": "string", + "description": "The branch, tag or SHA to check out." + }, + "token": { + "type": "string", + "description": "Personal access token or app installation token used to clone the repository." + }, + "ssh-key": { + "type": "string", + "description": "SSH key used to fetch the repository." + }, + "path": { + "type": "string", + "description": "Relative path under GITHUB_WORKSPACE to place the repository." + }, + "persist-credentials": { + "type": "boolean", + "description": "Whether to configure the token or SSH key with the local git config. Default: false." + }, + "clean": { + "type": "boolean", + "description": "Whether to execute git clean -ffdx and git reset --hard HEAD before fetching." + }, + "filter": { + "type": "string", + "description": "Partially clone the repository using a partial clone filter." + }, + "sparse-checkout": { + "type": "string", + "description": "Newline-separated list of patterns used for sparse-checkout." + }, + "sparse-checkout-cone-mode": { + "type": "boolean", + "description": "Whether to use cone-mode for sparse-checkout." + }, + "fetch-depth": { + "type": "integer", + "description": "Number of commits to fetch. 0 indicates all history." + }, + "fetch-tags": { + "type": "boolean", + "description": "Whether to fetch tags, even if fetch-depth > 0." + }, + "show-progress": { + "type": "boolean", + "description": "Whether to show progress status output when fetching." + }, + "lfs": { + "type": "boolean", + "description": "Whether to download Git-LFS files." + }, + "submodules": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": ["true", "false", "recursive"] + } + ], + "description": "Whether to checkout submodules." + }, + "set-safe-directory": { + "type": "boolean", + "description": "Whether to add the workspace to the safe.directory git config." + }, + "github-server-url": { + "type": "string", + "description": "The base URL for the GitHub instance that you are trying to clone from." + } + }, + "additionalProperties": false + }, + "minItems": 1 + } + ] } }, "additionalProperties": false, diff --git a/pkg/workflow/checkout_manager.go b/pkg/workflow/checkout_manager.go new file mode 100644 index 0000000000..75d724d023 --- /dev/null +++ b/pkg/workflow/checkout_manager.go @@ -0,0 +1,265 @@ +package workflow + +import ( + "fmt" + "strings" +) + +// CheckoutManager handles the generation of checkout steps from frontmatter configuration. +// It encapsulates all checkout logic, merging the user-defined checkout configuration +// with automatically generated checkout steps. +type CheckoutManager struct { + // customCheckouts holds user-defined checkout configurations from frontmatter + customCheckouts []CheckoutConfig + // trialMode indicates whether the workflow is running in trial mode + trialMode bool + // trialLogicalRepoSlug holds the target repository slug for trial mode + trialLogicalRepoSlug string +} + +// NewCheckoutManager creates a new CheckoutManager with the given configuration. +func NewCheckoutManager(customCheckouts []CheckoutConfig, trialMode bool, trialLogicalRepoSlug string) *CheckoutManager { + return &CheckoutManager{ + customCheckouts: customCheckouts, + trialMode: trialMode, + trialLogicalRepoSlug: trialLogicalRepoSlug, + } +} + +// GenerateMainCheckoutStep generates the main repository checkout step YAML lines. +// It merges user-defined checkout configuration with the default checkout behaviour. +// +// When no custom checkouts are specified, it falls back to the default checkout +// (persist-credentials: false, optional trial-mode fields). +// +// When a single custom checkout is specified without a path, it is treated as an +// override for the main repository checkout – its fields are merged on top of the +// defaults. +// +// When an array of custom checkouts is specified, only the first element (if it has +// no explicit path) is used as the main checkout override; remaining entries are +// returned by GenerateAdditionalCheckoutSteps. +func (m *CheckoutManager) GenerateMainCheckoutStep() []string { + var lines []string + lines = append(lines, " - name: Checkout repository\n") + lines = append(lines, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/checkout"))) + lines = append(lines, " with:\n") + + // Start from defaults. + persistCredentials := false + + // Collect overrides from user-specified first checkout (if it has no explicit path, + // meaning it targets the main repository rather than a secondary checkout). + var override *CheckoutConfig + if len(m.customCheckouts) > 0 && m.customCheckouts[0].Path == "" { + override = &m.customCheckouts[0] + } + + // Apply overrides from the user config. + var repository, ref, token, sshKey, filter, sparseCheckout, submodules, gitHubServerURL string + var fetchDepth *int + var fetchTags, showProgress, lfs, setSafeDirectory, sparseCheckoutConeMode *bool + var clean *bool + + if override != nil { + repository = override.Repository + ref = override.Ref + token = override.Token + sshKey = override.SSHKey + filter = override.Filter + sparseCheckout = override.SparseCheckout + submodules = override.Submodules + gitHubServerURL = override.GitConfigURL + fetchDepth = override.FetchDepth + fetchTags = override.FetchTags + showProgress = override.ShowProgress + lfs = override.Lfs + setSafeDirectory = override.SetSafeDirectory + sparseCheckoutConeMode = override.SparseCheckoutConeMode + clean = override.Clean + if override.PersistCredentials != nil { + persistCredentials = *override.PersistCredentials + } + } + + // Trial mode overrides: set repository and token when running in trial mode. + if m.trialMode { + if repository == "" && m.trialLogicalRepoSlug != "" { + repository = m.trialLogicalRepoSlug + } + if token == "" { + token = getEffectiveGitHubToken("") + } + } + + // Emit required fields. + if repository != "" { + lines = append(lines, fmt.Sprintf(" repository: %s\n", repository)) + } + if ref != "" { + lines = append(lines, fmt.Sprintf(" ref: %s\n", ref)) + } + if token != "" { + lines = append(lines, fmt.Sprintf(" token: %s\n", token)) + } + if sshKey != "" { + lines = append(lines, fmt.Sprintf(" ssh-key: %s\n", sshKey)) + } + lines = append(lines, fmt.Sprintf(" persist-credentials: %v\n", persistCredentials)) + if clean != nil { + lines = append(lines, fmt.Sprintf(" clean: %v\n", *clean)) + } + if filter != "" { + lines = append(lines, fmt.Sprintf(" filter: %s\n", filter)) + } + if sparseCheckout != "" { + lines = append(lines, " sparse-checkout: |\n") + for _, pattern := range strings.Split(strings.TrimSpace(sparseCheckout), "\n") { + lines = append(lines, fmt.Sprintf(" %s\n", strings.TrimSpace(pattern))) + } + } + if sparseCheckoutConeMode != nil { + lines = append(lines, fmt.Sprintf(" sparse-checkout-cone-mode: %v\n", *sparseCheckoutConeMode)) + } + if fetchDepth != nil { + lines = append(lines, fmt.Sprintf(" fetch-depth: %d\n", *fetchDepth)) + } + if fetchTags != nil { + lines = append(lines, fmt.Sprintf(" fetch-tags: %v\n", *fetchTags)) + } + if showProgress != nil { + lines = append(lines, fmt.Sprintf(" show-progress: %v\n", *showProgress)) + } + if lfs != nil { + lines = append(lines, fmt.Sprintf(" lfs: %v\n", *lfs)) + } + if submodules != "" { + lines = append(lines, fmt.Sprintf(" submodules: %s\n", submodules)) + } + if setSafeDirectory != nil { + lines = append(lines, fmt.Sprintf(" set-safe-directory: %v\n", *setSafeDirectory)) + } + if gitHubServerURL != "" { + lines = append(lines, fmt.Sprintf(" github-server-url: %s\n", gitHubServerURL)) + } + + return lines +} + +// GenerateAdditionalCheckoutSteps generates YAML lines for any extra checkout entries +// beyond the first/main one. Each additional checkout must have an explicit path. +// If a checkout in the array has no path, a path is automatically derived from the +// repository slug (last path segment) to ensure each checkout is in its own subfolder. +func (m *CheckoutManager) GenerateAdditionalCheckoutSteps() []string { + if len(m.customCheckouts) == 0 { + return nil + } + + // Determine which entries are "additional" (not the main checkout). + // The first entry is additional only when it already has an explicit path + // (meaning it does NOT override the main checkout). + startIdx := 0 + if len(m.customCheckouts) > 0 && m.customCheckouts[0].Path == "" { + // First entry was used as main checkout override – skip it here. + startIdx = 1 + } + + var lines []string + for i := startIdx; i < len(m.customCheckouts); i++ { + co := m.customCheckouts[i] + checkoutLines := m.generateAdditionalCheckoutStep(co, i) + lines = append(lines, checkoutLines...) + } + return lines +} + +// generateAdditionalCheckoutStep generates YAML lines for a single additional checkout. +// The index is used to create a unique default path when no path is specified. +func (m *CheckoutManager) generateAdditionalCheckoutStep(co CheckoutConfig, index int) []string { + // Derive the step name. + name := "Checkout" + if co.Repository != "" { + name = fmt.Sprintf("Checkout %s", co.Repository) + if co.Ref != "" { + name = fmt.Sprintf("Checkout %s@%s", co.Repository, co.Ref) + } + } + + // Derive the path if not explicitly set. + path := co.Path + if path == "" { + if co.Repository != "" { + // Use the repo name (last segment of owner/repo). + parts := strings.Split(co.Repository, "/") + path = parts[len(parts)-1] + } else { + // Fallback to a numbered path. + path = fmt.Sprintf("checkout-%d", index) + } + } + + var lines []string + lines = append(lines, fmt.Sprintf(" - name: %s\n", name)) + lines = append(lines, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/checkout"))) + lines = append(lines, " with:\n") + + if co.Repository != "" { + lines = append(lines, fmt.Sprintf(" repository: %s\n", co.Repository)) + } + if co.Ref != "" { + lines = append(lines, fmt.Sprintf(" ref: %s\n", co.Ref)) + } + if co.Token != "" { + lines = append(lines, fmt.Sprintf(" token: %s\n", co.Token)) + } + if co.SSHKey != "" { + lines = append(lines, fmt.Sprintf(" ssh-key: %s\n", co.SSHKey)) + } + lines = append(lines, fmt.Sprintf(" path: %s\n", path)) + + // Default persist-credentials to false for security. + persistCredentials := false + if co.PersistCredentials != nil { + persistCredentials = *co.PersistCredentials + } + lines = append(lines, fmt.Sprintf(" persist-credentials: %v\n", persistCredentials)) + + if co.Clean != nil { + lines = append(lines, fmt.Sprintf(" clean: %v\n", *co.Clean)) + } + if co.Filter != "" { + lines = append(lines, fmt.Sprintf(" filter: %s\n", co.Filter)) + } + if co.SparseCheckout != "" { + lines = append(lines, " sparse-checkout: |\n") + for _, pattern := range strings.Split(strings.TrimSpace(co.SparseCheckout), "\n") { + lines = append(lines, fmt.Sprintf(" %s\n", strings.TrimSpace(pattern))) + } + } + if co.SparseCheckoutConeMode != nil { + lines = append(lines, fmt.Sprintf(" sparse-checkout-cone-mode: %v\n", *co.SparseCheckoutConeMode)) + } + if co.FetchDepth != nil { + lines = append(lines, fmt.Sprintf(" fetch-depth: %d\n", *co.FetchDepth)) + } + if co.FetchTags != nil { + lines = append(lines, fmt.Sprintf(" fetch-tags: %v\n", *co.FetchTags)) + } + if co.ShowProgress != nil { + lines = append(lines, fmt.Sprintf(" show-progress: %v\n", *co.ShowProgress)) + } + if co.Lfs != nil { + lines = append(lines, fmt.Sprintf(" lfs: %v\n", *co.Lfs)) + } + if co.Submodules != "" { + lines = append(lines, fmt.Sprintf(" submodules: %s\n", co.Submodules)) + } + if co.SetSafeDirectory != nil { + lines = append(lines, fmt.Sprintf(" set-safe-directory: %v\n", *co.SetSafeDirectory)) + } + if co.GitConfigURL != "" { + lines = append(lines, fmt.Sprintf(" github-server-url: %s\n", co.GitConfigURL)) + } + + return lines +} diff --git a/pkg/workflow/checkout_manager_test.go b/pkg/workflow/checkout_manager_test.go new file mode 100644 index 0000000000..aedb6ab32b --- /dev/null +++ b/pkg/workflow/checkout_manager_test.go @@ -0,0 +1,199 @@ +//go:build !integration + +package workflow + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckoutManager_NoCustomCheckouts(t *testing.T) { + mgr := NewCheckoutManager(nil, false, "") + lines := mgr.GenerateMainCheckoutStep() + result := strings.Join(lines, "") + + assert.Contains(t, result, "name: Checkout repository", "should have default step name") + assert.Contains(t, result, "uses: actions/checkout", "should use actions/checkout") + assert.Contains(t, result, "persist-credentials: false", "should default to persist-credentials false") + assert.NotContains(t, result, "repository:", "should not have repository without custom config") + assert.NotContains(t, result, "ref:", "should not have ref without custom config") +} + +func TestCheckoutManager_SingleCheckout_NoPath(t *testing.T) { + // A single checkout without a path overrides the main checkout. + ref := "my-feature-branch" + co := CheckoutConfig{Ref: ref} + + mgr := NewCheckoutManager([]CheckoutConfig{co}, false, "") + lines := mgr.GenerateMainCheckoutStep() + result := strings.Join(lines, "") + + assert.Contains(t, result, "name: Checkout repository", "step name should still be 'Checkout repository'") + assert.Contains(t, result, "ref: my-feature-branch", "should include user-specified ref") + assert.Contains(t, result, "persist-credentials: false", "should keep persist-credentials false") + + // No additional steps because it was consumed as main checkout. + additional := mgr.GenerateAdditionalCheckoutSteps() + assert.Empty(t, additional, "no additional checkouts expected") +} + +func TestCheckoutManager_SingleCheckout_WithPath(t *testing.T) { + // A single checkout WITH a path is treated as an additional checkout (not the main one). + co := CheckoutConfig{ + Repository: "org/repo", + Ref: "main", + Path: "myrepo", + } + + mgr := NewCheckoutManager([]CheckoutConfig{co}, false, "") + + mainLines := mgr.GenerateMainCheckoutStep() + mainResult := strings.Join(mainLines, "") + // Main checkout should be default (no custom fields except persist-credentials) + assert.Contains(t, mainResult, "name: Checkout repository", "default main checkout") + assert.Contains(t, mainResult, "persist-credentials: false", "default persist-credentials") + assert.NotContains(t, mainResult, "org/repo", "main checkout should not reference additional repo") + + // Additional checkout should have the custom settings. + additional := mgr.GenerateAdditionalCheckoutSteps() + addResult := strings.Join(additional, "") + assert.Contains(t, addResult, "repository: org/repo", "should include repository") + assert.Contains(t, addResult, "ref: main", "should include ref") + assert.Contains(t, addResult, "path: myrepo", "should include path") + assert.Contains(t, addResult, "persist-credentials: false", "should default persist-credentials to false") +} + +func TestCheckoutManager_ArrayCheckout_FirstNoPath(t *testing.T) { + // Array: first entry has no path → becomes main checkout override. + // Remaining entries are additional checkouts. + checkouts := []CheckoutConfig{ + {Ref: "my-branch"}, + {Repository: "org/repo2", Path: "repo2"}, + } + + mgr := NewCheckoutManager(checkouts, false, "") + + mainLines := mgr.GenerateMainCheckoutStep() + mainResult := strings.Join(mainLines, "") + assert.Contains(t, mainResult, "ref: my-branch", "main checkout should use first entry's ref") + assert.NotContains(t, mainResult, "org/repo2", "main checkout should not include second entry") + + additional := mgr.GenerateAdditionalCheckoutSteps() + addResult := strings.Join(additional, "") + assert.Contains(t, addResult, "repository: org/repo2", "additional checkout should include second entry") + assert.Contains(t, addResult, "path: repo2", "additional checkout should include path") +} + +func TestCheckoutManager_ArrayCheckout_AllWithPath(t *testing.T) { + // Array: all entries have paths → all are additional checkouts, main is default. + checkouts := []CheckoutConfig{ + {Repository: "org/repo1", Path: "repo1"}, + {Repository: "org/repo2", Path: "repo2"}, + } + + mgr := NewCheckoutManager(checkouts, false, "") + + mainLines := mgr.GenerateMainCheckoutStep() + mainResult := strings.Join(mainLines, "") + assert.Contains(t, mainResult, "name: Checkout repository", "main checkout should be default") + assert.NotContains(t, mainResult, "org/repo1", "main checkout should not include custom repos") + + additional := mgr.GenerateAdditionalCheckoutSteps() + addResult := strings.Join(additional, "") + assert.Contains(t, addResult, "repository: org/repo1", "should include first additional checkout") + assert.Contains(t, addResult, "path: repo1", "should include first checkout path") + assert.Contains(t, addResult, "repository: org/repo2", "should include second additional checkout") + assert.Contains(t, addResult, "path: repo2", "should include second checkout path") +} + +func TestCheckoutManager_AdditionalCheckout_AutoPath(t *testing.T) { + // When an additional checkout has no path, it is auto-derived from the repository slug. + checkouts := []CheckoutConfig{ + {Path: "main-repo"}, // first has path → becomes additional (not main override) + {Repository: "org/mylib"}, // second has no path → auto-derived + } + + mgr := NewCheckoutManager(checkouts, false, "") + additional := mgr.GenerateAdditionalCheckoutSteps() + addResult := strings.Join(additional, "") + + // First additional entry + assert.Contains(t, addResult, "path: main-repo", "should keep explicit path for first entry") + // Second additional entry: path derived from "org/mylib" → "mylib" + assert.Contains(t, addResult, "path: mylib", "should auto-derive path from repo slug") +} + +func TestCheckoutManager_TrialMode(t *testing.T) { + mgr := NewCheckoutManager(nil, true, "owner/target-repo") + lines := mgr.GenerateMainCheckoutStep() + result := strings.Join(lines, "") + + assert.Contains(t, result, "repository: owner/target-repo", "should include trial logical repo") + assert.Contains(t, result, "token:", "should include token in trial mode") +} + +func TestCheckoutManager_CustomPersistCredentials(t *testing.T) { + co := CheckoutConfig{PersistCredentials: boolPtr(true)} + mgr := NewCheckoutManager([]CheckoutConfig{co}, false, "") + lines := mgr.GenerateMainCheckoutStep() + result := strings.Join(lines, "") + + assert.Contains(t, result, "persist-credentials: true", "should respect user-specified persist-credentials") +} + +func TestCheckoutManager_FetchDepth(t *testing.T) { + co := CheckoutConfig{FetchDepth: intPtr(0)} + mgr := NewCheckoutManager([]CheckoutConfig{co}, false, "") + lines := mgr.GenerateMainCheckoutStep() + result := strings.Join(lines, "") + + assert.Contains(t, result, "fetch-depth: 0", "should include fetch-depth 0 for full history") +} + +func TestCheckoutManager_SparseCheckout(t *testing.T) { + co := CheckoutConfig{SparseCheckout: "src/\ntest/"} + mgr := NewCheckoutManager([]CheckoutConfig{co}, false, "") + lines := mgr.GenerateMainCheckoutStep() + result := strings.Join(lines, "") + + assert.Contains(t, result, "sparse-checkout: |", "should include sparse-checkout block") + assert.Contains(t, result, "src/", "should include sparse-checkout patterns") + assert.Contains(t, result, "test/", "should include all sparse-checkout patterns") +} + +func TestParseCheckoutConfig_SingleObject(t *testing.T) { + input := map[string]any{ + "ref": "my-branch", + "fetch-depth": float64(1), + "persist-credentials": false, + } + + checkouts, err := parseCheckoutConfig(input) + require.NoError(t, err, "should parse single object without error") + assert.Len(t, checkouts, 1, "should return 1-element slice for single object") + assert.Equal(t, "my-branch", checkouts[0].Ref, "should parse ref correctly") + assert.NotNil(t, checkouts[0].FetchDepth, "should parse fetch-depth") + assert.Equal(t, 1, *checkouts[0].FetchDepth, "should have fetch-depth 1") +} + +func TestParseCheckoutConfig_Array(t *testing.T) { + input := []any{ + map[string]any{"ref": "branch1"}, + map[string]any{"repository": "org/repo2", "path": "repo2"}, + } + + checkouts, err := parseCheckoutConfig(input) + require.NoError(t, err, "should parse array without error") + assert.Len(t, checkouts, 2, "should return 2-element slice for array input") + assert.Equal(t, "branch1", checkouts[0].Ref) + assert.Equal(t, "org/repo2", checkouts[1].Repository) + assert.Equal(t, "repo2", checkouts[1].Path) +} + +func TestParseCheckoutConfig_InvalidInput(t *testing.T) { + _, err := parseCheckoutConfig("not-an-object") + assert.Error(t, err, "should return error for invalid input type") +} diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index c816158332..96843803b7 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -176,6 +176,17 @@ func (c *Compiler) extractYAMLSections(frontmatter map[string]any, workflowData workflowData.Environment = c.extractTopLevelYAMLSection(frontmatter, "environment") workflowData.Container = c.extractTopLevelYAMLSection(frontmatter, "container") workflowData.Cache = c.extractTopLevelYAMLSection(frontmatter, "cache") + + // Extract checkout configuration (single object or array) + if checkoutRaw, hasCheckout := frontmatter["checkout"]; hasCheckout && checkoutRaw != nil { + checkouts, err := parseCheckoutConfig(checkoutRaw) + if err == nil { + workflowData.CustomCheckouts = checkouts + orchestratorWorkflowLog.Printf("Extracted %d custom checkout(s) from frontmatter", len(checkouts)) + } else { + orchestratorWorkflowLog.Printf("Failed to parse checkout config: %v", err) + } + } } // processAndMergeSteps handles the merging of imported steps with main workflow steps diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 83277a0690..c571113abf 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -460,6 +460,7 @@ type WorkflowData struct { ActionPinWarnings map[string]bool // cache of already-warned action pin failures (key: "repo@version") ActionMode ActionMode // action mode for workflow compilation (dev, release, script) HasExplicitGitHubTool bool // true if tools.github was explicitly configured in frontmatter + CustomCheckouts []CheckoutConfig // custom checkout configurations from frontmatter checkout field } // BaseSafeOutputConfig holds common configuration fields for all safe output types diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go index 3c3aa82340..fefe05c550 100644 --- a/pkg/workflow/compiler_yaml_main_job.go +++ b/pkg/workflow/compiler_yaml_main_job.go @@ -17,22 +17,16 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat // Add checkout step first if needed if needsCheckout { - yaml.WriteString(" - name: Checkout repository\n") - fmt.Fprintf(yaml, " uses: %s\n", GetActionPin("actions/checkout")) - // Always add with section for persist-credentials - yaml.WriteString(" with:\n") - yaml.WriteString(" persist-credentials: false\n") - // In trial mode without cloning, checkout the logical repo if specified - if c.trialMode { - if c.trialLogicalRepoSlug != "" { - fmt.Fprintf(yaml, " repository: %s\n", c.trialLogicalRepoSlug) - // trialTargetRepoName := strings.Split(c.trialLogicalRepoSlug, "/") - // if len(trialTargetRepoName) == 2 { - // yaml.WriteString(fmt.Sprintf(" path: %s\n", trialTargetRepoName[1])) - // } - } - effectiveToken := getEffectiveGitHubToken("") - fmt.Fprintf(yaml, " token: %s\n", effectiveToken) + // Use the checkout manager to generate the main checkout step, + // merging any user-specified checkout config from frontmatter. + checkoutMgr := NewCheckoutManager(data.CustomCheckouts, c.trialMode, c.trialLogicalRepoSlug) + for _, line := range checkoutMgr.GenerateMainCheckoutStep() { + yaml.WriteString(line) + } + + // Add additional checkout steps (when checkout is an array with multiple entries). + for _, line := range checkoutMgr.GenerateAdditionalCheckoutSteps() { + yaml.WriteString(line) } // Add CLI build steps in dev mode (after automatic checkout, before other steps) diff --git a/pkg/workflow/frontmatter_checkout_test.go b/pkg/workflow/frontmatter_checkout_test.go new file mode 100644 index 0000000000..b2612720ab --- /dev/null +++ b/pkg/workflow/frontmatter_checkout_test.go @@ -0,0 +1,181 @@ +//go:build integration + +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestFrontmatterCheckout_SingleObject verifies that a single checkout object in frontmatter +// is used to override the main repository checkout step. +func TestFrontmatterCheckout_SingleObject(t *testing.T) { + frontmatter := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: read +engine: copilot +strict: false +checkout: + ref: my-feature-branch + fetch-depth: 0 +---` + markdown := "# Agent\n\nComplete the task." + + tmpDir := testutil.TempDir(t, "frontmatter-checkout-single-test") + workflowPath := filepath.Join(tmpDir, "test.md") + require.NoError(t, os.WriteFile(workflowPath, []byte(frontmatter+"\n\n"+markdown), 0644)) + + compiler := NewCompiler() + require.NoError(t, compiler.CompileWorkflow(workflowPath)) + + lockFile := strings.TrimSuffix(workflowPath, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockFile) + require.NoError(t, err) + + lockStr := string(lockContent) + + // The main checkout step should include the user-specified fields. + assert.Contains(t, lockStr, "name: Checkout repository", "should have main checkout step") + assert.Contains(t, lockStr, "ref: my-feature-branch", "should include user-specified ref") + assert.Contains(t, lockStr, "fetch-depth: 0", "should include fetch-depth") + assert.Contains(t, lockStr, "persist-credentials: false", "should keep persist-credentials false") +} + +// TestFrontmatterCheckout_ArrayMultiple verifies that an array of checkout objects generates +// the main checkout plus additional checkout steps, each in its own subfolder. +func TestFrontmatterCheckout_ArrayMultiple(t *testing.T) { + frontmatter := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: read +engine: copilot +strict: false +checkout: + - ref: main + - repository: org/tools + ref: v2.0.0 + path: tools +---` + markdown := "# Agent\n\nComplete the task." + + tmpDir := testutil.TempDir(t, "frontmatter-checkout-array-test") + workflowPath := filepath.Join(tmpDir, "test.md") + require.NoError(t, os.WriteFile(workflowPath, []byte(frontmatter+"\n\n"+markdown), 0644)) + + compiler := NewCompiler() + require.NoError(t, compiler.CompileWorkflow(workflowPath)) + + lockFile := strings.TrimSuffix(workflowPath, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockFile) + require.NoError(t, err) + + lockStr := string(lockContent) + + // Main checkout step should reflect first array entry (no path → main checkout override). + assert.Contains(t, lockStr, "name: Checkout repository", "should have main checkout step") + assert.Contains(t, lockStr, "ref: main", "first array entry without path should override main checkout ref") + + // Additional checkout for org/tools. + assert.Contains(t, lockStr, "repository: org/tools", "should include additional repository") + assert.Contains(t, lockStr, "ref: v2.0.0", "should include additional ref") + assert.Contains(t, lockStr, "path: tools", "should include explicit path for additional checkout") +} + +// TestFrontmatterCheckout_ArrayAllWithPaths verifies that when all array entries have explicit paths, +// all of them are emitted as additional checkouts and the main checkout uses defaults. +func TestFrontmatterCheckout_ArrayAllWithPaths(t *testing.T) { + frontmatter := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: read +engine: copilot +strict: false +checkout: + - repository: org/repo1 + path: repo1 + - repository: org/repo2 + ref: develop + path: repo2 +---` + markdown := "# Agent\n\nComplete the task." + + tmpDir := testutil.TempDir(t, "frontmatter-checkout-all-paths-test") + workflowPath := filepath.Join(tmpDir, "test.md") + require.NoError(t, os.WriteFile(workflowPath, []byte(frontmatter+"\n\n"+markdown), 0644)) + + compiler := NewCompiler() + require.NoError(t, compiler.CompileWorkflow(workflowPath)) + + lockFile := strings.TrimSuffix(workflowPath, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockFile) + require.NoError(t, err) + + lockStr := string(lockContent) + + // Main checkout should be the default. + assert.Contains(t, lockStr, "name: Checkout repository", "should have default main checkout") + + // Both additional checkouts. + assert.Contains(t, lockStr, "repository: org/repo1", "should include repo1") + assert.Contains(t, lockStr, "path: repo1", "should include path for repo1") + assert.Contains(t, lockStr, "repository: org/repo2", "should include repo2") + assert.Contains(t, lockStr, "ref: develop", "should include ref for repo2") + assert.Contains(t, lockStr, "path: repo2", "should include path for repo2") + + // Check ordering: main checkout before additional checkouts. + mainIdx := strings.Index(lockStr, "name: Checkout repository") + repo1Idx := strings.Index(lockStr, "repository: org/repo1") + assert.Less(t, mainIdx, repo1Idx, "main checkout should come before additional checkouts") +} + +// TestFrontmatterCheckout_AutoPath verifies that when an additional checkout has no path, +// the path is automatically derived from the repository slug. +func TestFrontmatterCheckout_AutoPath(t *testing.T) { + frontmatter := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: read +engine: copilot +strict: false +checkout: + - path: main + - repository: org/mytools +---` + markdown := "# Agent\n\nComplete the task." + + tmpDir := testutil.TempDir(t, "frontmatter-checkout-autopath-test") + workflowPath := filepath.Join(tmpDir, "test.md") + require.NoError(t, os.WriteFile(workflowPath, []byte(frontmatter+"\n\n"+markdown), 0644)) + + compiler := NewCompiler() + require.NoError(t, compiler.CompileWorkflow(workflowPath)) + + lockFile := strings.TrimSuffix(workflowPath, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockFile) + require.NoError(t, err) + + lockStr := string(lockContent) + + // Second additional checkout: path auto-derived from "org/mytools" → "mytools" + assert.Contains(t, lockStr, "repository: org/mytools", "should include the repo") + assert.Contains(t, lockStr, "path: mytools", "should auto-derive path from repo slug") +} diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 41ed80ed57..9ad13075cb 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -86,6 +86,28 @@ type RateLimitConfig struct { IgnoredRoles []string `json:"ignored-roles,omitempty"` // Roles that are exempt from rate limiting (e.g., ["admin", "maintainer"]) } +// CheckoutConfig represents a single actions/checkout configuration. +// Supports the same fields as the actions/checkout action. +type CheckoutConfig struct { + Repository string `json:"repository,omitempty"` // Repository to check out (default: current repo) + Ref string `json:"ref,omitempty"` // Branch, tag, or SHA to check out + Token string `json:"token,omitempty"` // Personal access token or app token + SSHKey string `json:"ssh-key,omitempty"` // SSH key used to fetch the repository + Path string `json:"path,omitempty"` // Relative path under GITHUB_WORKSPACE to place the repository + PersistCredentials *bool `json:"persist-credentials,omitempty"` // Whether to persist credentials after checkout (default: false for security) + Clean *bool `json:"clean,omitempty"` // Whether to run git clean before fetching + Filter string `json:"filter,omitempty"` // Partial clone filter (e.g. "blob:none") + SparseCheckout string `json:"sparse-checkout,omitempty"` // List of patterns for sparse checkout + SparseCheckoutConeMode *bool `json:"sparse-checkout-cone-mode,omitempty"` // Whether to use cone mode for sparse checkout + FetchDepth *int `json:"fetch-depth,omitempty"` // Number of commits to fetch; 0 for all history + FetchTags *bool `json:"fetch-tags,omitempty"` // Whether to fetch tags even when fetch-depth > 0 + ShowProgress *bool `json:"show-progress,omitempty"` // Whether to show progress while fetching + Lfs *bool `json:"lfs,omitempty"` // Whether to download Git-LFS files + Submodules string `json:"submodules,omitempty"` // Whether to checkout submodules ("true", "false", "recursive") + SetSafeDirectory *bool `json:"set-safe-directory,omitempty"` // Whether to add the workspace to the safe.directory git config + GitConfigURL string `json:"github-server-url,omitempty"` // URL of the GitHub server (for GHES) +} + // FrontmatterConfig represents the structured configuration from workflow frontmatter // This provides compile-time type safety and clearer error messages compared to map[string]any type FrontmatterConfig struct { @@ -143,6 +165,12 @@ type FrontmatterConfig struct { Services map[string]any `json:"services,omitempty"` Cache map[string]any `json:"cache,omitempty"` + // Checkout configuration - supports single object or array of objects. + // Each object accepts the same fields as actions/checkout. + // When an array is provided, each checkout is created in its own subfolder. + Checkout any `json:"checkout,omitempty"` // Can be CheckoutConfig or []CheckoutConfig + CheckoutTyped []CheckoutConfig `json:"-"` // Parsed checkout configurations (not in JSON) + // Import and inclusion Imports any `json:"imports,omitempty"` // Can be string or array Include any `json:"include,omitempty"` // Can be string or array @@ -246,6 +274,15 @@ func ParseFrontmatterConfig(frontmatter map[string]any) (*FrontmatterConfig, err } } + // Parse checkout field - supports single object or array of objects + if config.Checkout != nil { + checkouts, err := parseCheckoutConfig(config.Checkout) + if err == nil { + config.CheckoutTyped = checkouts + frontmatterTypesLog.Printf("Parsed checkout config: %d checkouts", len(checkouts)) + } + } + frontmatterTypesLog.Printf("Successfully parsed frontmatter config: name=%s, engine=%s", config.Name, config.Engine) return &config, nil } @@ -419,6 +456,30 @@ func parsePluginsConfig(plugins any) ([]string, string, error) { return nil, "", fmt.Errorf("plugins must be either an array of strings or an object with 'repos' field") } +// parseCheckoutConfig parses the checkout field which can be either: +// 1. Single object format: { "ref": "main", "token": "..." } +// 2. Array format: [{ "repository": "org/repo1", "path": "repo1" }, { ... }] +// Returns a slice of CheckoutConfig (single object becomes a 1-element slice). +func parseCheckoutConfig(checkout any) ([]CheckoutConfig, error) { + jsonBytes, err := json.Marshal(checkout) + if err != nil { + return nil, fmt.Errorf("failed to marshal checkout to JSON: %w", err) + } + + // Try array format first + var checkouts []CheckoutConfig + if err := json.Unmarshal(jsonBytes, &checkouts); err == nil { + return checkouts, nil + } + + // Try single object format + var single CheckoutConfig + if err := json.Unmarshal(jsonBytes, &single); err != nil { + return nil, fmt.Errorf("checkout must be either an object or an array of objects: %w", err) + } + return []CheckoutConfig{single}, nil +} + // countRuntimes counts the number of non-nil runtimes in RuntimesConfig func countRuntimes(config *RuntimesConfig) int { if config == nil { @@ -650,6 +711,17 @@ func (fc *FrontmatterConfig) ToMap() map[string]any { result["cache"] = fc.Cache } + // Checkout - use typed if available, otherwise fall back to original + if len(fc.CheckoutTyped) > 0 { + if len(fc.CheckoutTyped) == 1 { + result["checkout"] = fc.CheckoutTyped[0] + } else { + result["checkout"] = fc.CheckoutTyped + } + } else if fc.Checkout != nil { + result["checkout"] = fc.Checkout + } + // Import and inclusion if fc.Imports != nil { result["imports"] = fc.Imports From ff5ae1037bdd0afb1dec40731c4c8f58cc1def07 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 00:54:32 +0000 Subject: [PATCH 4/6] Add checkout merging from imported agentic workflows with tests - Add extractCheckoutFromContent in content_extractor.go - Add MergedCheckouts to ImportsResult with accumulation logic - Add checkoutsToJSON helper with error logging - Merge imported checkouts into workflowData.CustomCheckouts in orchestrator - Add 3 integration tests for imported checkout scenarios Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/content_extractor.go | 6 + pkg/parser/import_processor.go | 37 ++++ .../compiler_orchestrator_workflow.go | 16 ++ pkg/workflow/frontmatter_checkout_test.go | 183 ++++++++++++++++++ 4 files changed, 242 insertions(+) diff --git a/pkg/parser/content_extractor.go b/pkg/parser/content_extractor.go index c99321dd1c..222b127a14 100644 --- a/pkg/parser/content_extractor.go +++ b/pkg/parser/content_extractor.go @@ -192,6 +192,12 @@ func extractCacheFromContent(content string) (string, error) { return extractFrontmatterField(content, "cache", "{}") } +// extractCheckoutFromContent extracts checkout section from frontmatter as JSON string. +// The checkout field can be a single object or an array of checkout config objects. +func extractCheckoutFromContent(content string) (string, error) { + return extractFrontmatterField(content, "checkout", "") +} + // extractFeaturesFromContent extracts features section from frontmatter as JSON string func extractFeaturesFromContent(content string) (string, error) { return extractFrontmatterField(content, "features", "{}") diff --git a/pkg/parser/import_processor.go b/pkg/parser/import_processor.go index 6605d22af1..9aff76fcf5 100644 --- a/pkg/parser/import_processor.go +++ b/pkg/parser/import_processor.go @@ -36,6 +36,7 @@ type ImportsResult struct { MergedPostSteps string // Merged post-steps configuration from all imports (appended in order) MergedLabels []string // Merged labels from all imports (union of label names) MergedCaches []string // Merged cache configurations from all imports (appended in order) + MergedCheckouts string // Merged checkout configurations from all imports (JSON array) MergedJobs string // Merged jobs from imported YAML workflows (JSON format) MergedFeatures []map[string]any // Merged features configuration from all imports (parsed YAML structures) ImportedFiles []string // List of imported file paths (for manifest) @@ -263,6 +264,7 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a var skipBots []string // Track unique skip-bots skipBotsSet := make(map[string]bool) // Set for deduplicating skip-bots var caches []string // Track cache configurations (appended in order) + var checkouts []any // Track checkout configurations from imports (as JSON-parsed values) var jobsBuilder strings.Builder // Track jobs from imported YAML workflows var features []map[string]any // Track features configurations from imports (parsed structures) var agentFile string // Track custom agent file @@ -791,6 +793,26 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a caches = append(caches, cacheContent) } + // Extract checkout from imported file (append all checkout configs as additional checkouts) + checkoutContent, err := extractCheckoutFromContent(string(content)) + if err == nil && checkoutContent != "" { + // The checkout field can be a single object or an array of objects. + // Normalise to []any so we can accumulate across multiple imports. + var checkoutRaw any + if jsonErr := json.Unmarshal([]byte(checkoutContent), &checkoutRaw); jsonErr != nil { + log.Printf("Warning: failed to parse checkout config from import %s: %v", item.fullPath, jsonErr) + } else { + switch v := checkoutRaw.(type) { + case []any: + checkouts = append(checkouts, v...) + log.Printf("Extracted %d checkout(s) from import: %s", len(v), item.fullPath) + case map[string]any: + checkouts = append(checkouts, v) + log.Printf("Extracted 1 checkout from import: %s", item.fullPath) + } + } + } + // Extract features from imported file (parse as map structure) featuresContent, err := extractFeaturesFromContent(string(content)) if err == nil && featuresContent != "" && featuresContent != "{}" { @@ -835,6 +857,7 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a MergedPostSteps: postStepsBuilder.String(), MergedLabels: labels, MergedCaches: caches, + MergedCheckouts: checkoutsToJSON(checkouts), MergedJobs: jobsBuilder.String(), MergedFeatures: features, ImportedFiles: topologicalOrder, @@ -845,6 +868,20 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a }, nil } +// checkoutsToJSON serialises a slice of checkout config values to a JSON array string. +// Returns an empty string if the slice is empty. +func checkoutsToJSON(checkouts []any) string { + if len(checkouts) == 0 { + return "" + } + data, err := json.Marshal(checkouts) + if err != nil { + log.Printf("Warning: failed to serialise merged checkout configs to JSON: %v", err) + return "" + } + return string(data) +} + // findCyclePath uses DFS to find a complete cycle path in the dependency graph // Returns a path showing the full chain including the back-edge (e.g., ["b.md", "c.md", "d.md", "b.md"]) func findCyclePath(cycleNodes map[string]bool, dependencies map[string][]string) []string { diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 96843803b7..bc2f2341b1 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -75,6 +75,22 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) workflowData.Features = mergedFeatures } + // Merge checkout configurations from imports (appended as additional checkouts) + if engineSetup.importsResult.MergedCheckouts != "" { + var importedCheckouts []any + if err := json.Unmarshal([]byte(engineSetup.importsResult.MergedCheckouts), &importedCheckouts); err != nil { + orchestratorWorkflowLog.Printf("Warning: failed to unmarshal merged checkouts from imports: %v", err) + } else { + parsed, parseErr := parseCheckoutConfig(importedCheckouts) + if parseErr != nil { + orchestratorWorkflowLog.Printf("Warning: failed to parse merged checkouts from imports: %v", parseErr) + } else { + workflowData.CustomCheckouts = append(workflowData.CustomCheckouts, parsed...) + orchestratorWorkflowLog.Printf("Merged %d checkout(s) from imports", len(parsed)) + } + } + } + // Process and merge custom steps with imported steps c.processAndMergeSteps(result.Frontmatter, workflowData, engineSetup.importsResult) diff --git a/pkg/workflow/frontmatter_checkout_test.go b/pkg/workflow/frontmatter_checkout_test.go index b2612720ab..c4de391d44 100644 --- a/pkg/workflow/frontmatter_checkout_test.go +++ b/pkg/workflow/frontmatter_checkout_test.go @@ -179,3 +179,186 @@ checkout: assert.Contains(t, lockStr, "repository: org/mytools", "should include the repo") assert.Contains(t, lockStr, "path: mytools", "should auto-derive path from repo slug") } + +// TestFrontmatterCheckout_ImportedSingleCheckout verifies that a checkout field in an imported +// agentic workflow is merged into the main workflow as an additional checkout. +func TestFrontmatterCheckout_ImportedSingleCheckout(t *testing.T) { + tmpDir := testutil.TempDir(t, "frontmatter-checkout-import-single-test") + + // Shared/imported workflow that declares a checkout for an extra repo + importContent := `--- +checkout: + repository: org/shared-tools + ref: v1.0.0 + path: shared-tools +--- + +# Shared Tools + +Use shared tools from org/shared-tools. +` + importPath := filepath.Join(tmpDir, "shared.md") + require.NoError(t, os.WriteFile(importPath, []byte(importContent), 0644)) + + // Main workflow that imports the shared workflow + mainContent := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: read +engine: copilot +strict: false +imports: + - shared.md +--- + +# Main Workflow + +Complete the task. +` + workflowPath := filepath.Join(tmpDir, "main.md") + require.NoError(t, os.WriteFile(workflowPath, []byte(mainContent), 0644)) + + compiler := NewCompiler() + require.NoError(t, compiler.CompileWorkflow(workflowPath)) + + lockFile := strings.TrimSuffix(workflowPath, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockFile) + require.NoError(t, err) + + lockStr := string(lockContent) + + // The imported checkout should appear as an additional checkout step + assert.Contains(t, lockStr, "repository: org/shared-tools", "should include imported repository") + assert.Contains(t, lockStr, "ref: v1.0.0", "should include imported ref") + assert.Contains(t, lockStr, "path: shared-tools", "should include imported path") + assert.Contains(t, lockStr, "persist-credentials: false", "should default persist-credentials to false") + + // Main checkout should still be present + assert.Contains(t, lockStr, "name: Checkout repository", "should still have main checkout") + + // Main checkout should come before the imported additional checkout + mainIdx := strings.Index(lockStr, "name: Checkout repository") + importedIdx := strings.Index(lockStr, "repository: org/shared-tools") + assert.Less(t, mainIdx, importedIdx, "main checkout should precede imported additional checkout") +} + +// TestFrontmatterCheckout_ImportedArrayCheckout verifies that multiple checkout entries in an +// imported agentic workflow are all merged as additional checkouts. +func TestFrontmatterCheckout_ImportedArrayCheckout(t *testing.T) { + tmpDir := testutil.TempDir(t, "frontmatter-checkout-import-array-test") + + // Shared/imported workflow that declares multiple checkouts + importContent := `--- +checkout: + - repository: org/lib-a + path: lib-a + - repository: org/lib-b + ref: develop + path: lib-b +--- + +# Shared Libraries +` + importPath := filepath.Join(tmpDir, "libs.md") + require.NoError(t, os.WriteFile(importPath, []byte(importContent), 0644)) + + mainContent := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: read +engine: copilot +strict: false +imports: + - libs.md +--- + +# Main Workflow +` + workflowPath := filepath.Join(tmpDir, "main.md") + require.NoError(t, os.WriteFile(workflowPath, []byte(mainContent), 0644)) + + compiler := NewCompiler() + require.NoError(t, compiler.CompileWorkflow(workflowPath)) + + lockFile := strings.TrimSuffix(workflowPath, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockFile) + require.NoError(t, err) + + lockStr := string(lockContent) + + // Both imported checkouts should be present + assert.Contains(t, lockStr, "repository: org/lib-a", "should include first imported checkout") + assert.Contains(t, lockStr, "path: lib-a", "should include first imported path") + assert.Contains(t, lockStr, "repository: org/lib-b", "should include second imported checkout") + assert.Contains(t, lockStr, "ref: develop", "should include second imported ref") + assert.Contains(t, lockStr, "path: lib-b", "should include second imported path") +} + +// TestFrontmatterCheckout_MainAndImportedMerged verifies that checkout configs from both the +// main workflow and an imported workflow are merged: the main workflow's config controls the main +// checkout step, and the imported workflow's checkout(s) are appended as additional checkouts. +func TestFrontmatterCheckout_MainAndImportedMerged(t *testing.T) { + tmpDir := testutil.TempDir(t, "frontmatter-checkout-main-and-import-test") + + // Imported workflow declares an additional checkout + importContent := `--- +checkout: + repository: org/data + ref: main + path: data +--- + +# Data +` + importPath := filepath.Join(tmpDir, "data.md") + require.NoError(t, os.WriteFile(importPath, []byte(importContent), 0644)) + + // Main workflow overrides the main checkout ref AND imports the shared workflow + mainContent := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: read +engine: copilot +strict: false +checkout: + ref: my-branch +imports: + - data.md +--- + +# Main Workflow +` + workflowPath := filepath.Join(tmpDir, "main.md") + require.NoError(t, os.WriteFile(workflowPath, []byte(mainContent), 0644)) + + compiler := NewCompiler() + require.NoError(t, compiler.CompileWorkflow(workflowPath)) + + lockFile := strings.TrimSuffix(workflowPath, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockFile) + require.NoError(t, err) + + lockStr := string(lockContent) + + // Main checkout should use the main workflow's ref override + assert.Contains(t, lockStr, "name: Checkout repository", "should have main checkout step") + assert.Contains(t, lockStr, "ref: my-branch", "main checkout should use main workflow's ref") + + // The imported additional checkout should also be present + assert.Contains(t, lockStr, "repository: org/data", "should include imported repo") + assert.Contains(t, lockStr, "path: data", "should include imported path") + + // Main checkout precedes imported additional checkout + mainCheckoutIdx := strings.Index(lockStr, "name: Checkout repository") + importedCheckoutIdx := strings.Index(lockStr, "repository: org/data") + assert.Less(t, mainCheckoutIdx, importedCheckoutIdx, "main checkout should come before imported checkout") +} From 9e875e5e9b319ce9920620fd0e198e64d8ff0d4a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 06:03:20 +0000 Subject: [PATCH 5/6] Add tests for multiple checkouts with tokens, fetch depths, and persist-credentials enforcement Unit tests (checkout_manager_test.go): - TestCheckoutManager_MultipleCheckouts_DifferentTokens - TestCheckoutManager_MultipleCheckouts_DifferentFetchDepths - TestCheckoutManager_PersistCredentialsFalseDefault_AllCheckouts - TestCheckoutManager_AdditionalCheckout_TokenNotPropagatedToMain Integration tests (frontmatter_checkout_test.go): - TestFrontmatterCheckout_MultipleCheckouts_DifferentTokens - TestFrontmatterCheckout_MultipleCheckouts_DifferentFetchDepths - TestFrontmatterCheckout_PersistCredentialsFalse_Enforced Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/checkout_manager_test.go | 102 +++++++++++++++ pkg/workflow/frontmatter_checkout_test.go | 145 ++++++++++++++++++++++ 2 files changed, 247 insertions(+) diff --git a/pkg/workflow/checkout_manager_test.go b/pkg/workflow/checkout_manager_test.go index aedb6ab32b..3115294a12 100644 --- a/pkg/workflow/checkout_manager_test.go +++ b/pkg/workflow/checkout_manager_test.go @@ -197,3 +197,105 @@ func TestParseCheckoutConfig_InvalidInput(t *testing.T) { _, err := parseCheckoutConfig("not-an-object") assert.Error(t, err, "should return error for invalid input type") } + +// TestCheckoutManager_MultipleCheckouts_DifferentTokens verifies that multiple additional checkouts +// can each carry their own token and that persist-credentials defaults to false for all of them. +func TestCheckoutManager_MultipleCheckouts_DifferentTokens(t *testing.T) { + checkouts := []CheckoutConfig{ + { + Repository: "org/repo-a", + Ref: "main", + Token: "${{ secrets.TOKEN_A }}", + Path: "repo-a", + }, + { + Repository: "org/repo-b", + Ref: "develop", + Token: "${{ secrets.TOKEN_B }}", + Path: "repo-b", + }, + } + + mgr := NewCheckoutManager(checkouts, false, "") + additional := mgr.GenerateAdditionalCheckoutSteps() + addResult := strings.Join(additional, "") + + // Both checkouts should appear with their respective tokens + assert.Contains(t, addResult, "repository: org/repo-a", "should include repo-a") + assert.Contains(t, addResult, "token: ${{ secrets.TOKEN_A }}", "should include token for repo-a") + assert.Contains(t, addResult, "repository: org/repo-b", "should include repo-b") + assert.Contains(t, addResult, "token: ${{ secrets.TOKEN_B }}", "should include token for repo-b") + + // persist-credentials must be false for every additional checkout + // Count occurrences to confirm both checkouts have it set + persistFalseCount := strings.Count(addResult, "persist-credentials: false") + assert.Equal(t, 2, persistFalseCount, "every additional checkout must have persist-credentials: false") +} + +// TestCheckoutManager_MultipleCheckouts_DifferentFetchDepths verifies that multiple additional +// checkouts can specify different fetch depths. +func TestCheckoutManager_MultipleCheckouts_DifferentFetchDepths(t *testing.T) { + checkouts := []CheckoutConfig{ + // First entry: no path → main checkout override with fetch-depth 0 (full history) + {FetchDepth: intPtr(0)}, + // Second entry: additional checkout with shallow clone (depth 1) + {Repository: "org/large-repo", Path: "large-repo", FetchDepth: intPtr(1)}, + // Third entry: additional checkout with no explicit fetch-depth (omitted → actions/checkout default) + {Repository: "org/small-repo", Path: "small-repo"}, + } + + mgr := NewCheckoutManager(checkouts, false, "") + + mainLines := mgr.GenerateMainCheckoutStep() + mainResult := strings.Join(mainLines, "") + assert.Contains(t, mainResult, "fetch-depth: 0", "main checkout should have full history (depth 0)") + assert.Contains(t, mainResult, "persist-credentials: false", "main checkout must have persist-credentials: false") + + additional := mgr.GenerateAdditionalCheckoutSteps() + addResult := strings.Join(additional, "") + assert.Contains(t, addResult, "repository: org/large-repo", "should include large-repo") + assert.Contains(t, addResult, "fetch-depth: 1", "large-repo should have shallow clone depth 1") + assert.Contains(t, addResult, "repository: org/small-repo", "should include small-repo") + assert.NotContains(t, addResult, "fetch-depth: 0", "small-repo should not have a fetch-depth line (omitted)") +} + +// TestCheckoutManager_PersistCredentialsFalseDefault_AllCheckouts verifies that persist-credentials +// defaults to false for every generated checkout step when not explicitly set by the user. +func TestCheckoutManager_PersistCredentialsFalseDefault_AllCheckouts(t *testing.T) { + checkouts := []CheckoutConfig{ + // Main checkout override (no path) + {Ref: "release/1.0"}, + // Additional checkouts + {Repository: "org/lib1", Path: "lib1"}, + {Repository: "org/lib2", Path: "lib2"}, + {Repository: "org/lib3", Path: "lib3", Token: "${{ secrets.LIB3_TOKEN }}"}, + } + + mgr := NewCheckoutManager(checkouts, false, "") + + mainResult := strings.Join(mgr.GenerateMainCheckoutStep(), "") + assert.Contains(t, mainResult, "persist-credentials: false", "main checkout must default to persist-credentials: false") + + additionalResult := strings.Join(mgr.GenerateAdditionalCheckoutSteps(), "") + // Three additional checkouts, each must have persist-credentials: false + persistFalseCount := strings.Count(additionalResult, "persist-credentials: false") + assert.Equal(t, 3, persistFalseCount, "all 3 additional checkouts must have persist-credentials: false") +} + +// TestCheckoutManager_AdditionalCheckout_TokenNotPropagatedToMain verifies that a token specified +// in an additional checkout (one with a path) is NOT propagated to the main checkout step. +func TestCheckoutManager_AdditionalCheckout_TokenNotPropagatedToMain(t *testing.T) { + checkouts := []CheckoutConfig{ + {Repository: "org/private-data", Path: "data", Token: "${{ secrets.DATA_TOKEN }}"}, + } + + mgr := NewCheckoutManager(checkouts, false, "") + + mainResult := strings.Join(mgr.GenerateMainCheckoutStep(), "") + // The main checkout step must NOT inherit the token from the additional checkout + assert.NotContains(t, mainResult, "${{ secrets.DATA_TOKEN }}", "main checkout must not inherit token from additional checkout") + assert.Contains(t, mainResult, "persist-credentials: false", "main checkout must still have persist-credentials: false") + + addResult := strings.Join(mgr.GenerateAdditionalCheckoutSteps(), "") + assert.Contains(t, addResult, "token: ${{ secrets.DATA_TOKEN }}", "additional checkout should have its token") +} diff --git a/pkg/workflow/frontmatter_checkout_test.go b/pkg/workflow/frontmatter_checkout_test.go index c4de391d44..ede58cbdb5 100644 --- a/pkg/workflow/frontmatter_checkout_test.go +++ b/pkg/workflow/frontmatter_checkout_test.go @@ -362,3 +362,148 @@ imports: importedCheckoutIdx := strings.Index(lockStr, "repository: org/data") assert.Less(t, mainCheckoutIdx, importedCheckoutIdx, "main checkout should come before imported checkout") } + +// TestFrontmatterCheckout_MultipleCheckouts_DifferentTokens verifies that an array of checkout +// entries with distinct tokens compiles correctly and that every step has persist-credentials: false. +func TestFrontmatterCheckout_MultipleCheckouts_DifferentTokens(t *testing.T) { + frontmatter := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: read +engine: copilot +strict: false +checkout: + - ref: main + - repository: org/private-a + ref: v1.0.0 + path: private-a + token: ${{ secrets.TOKEN_A }} + - repository: org/private-b + ref: develop + path: private-b + token: ${{ secrets.TOKEN_B }} +---` + markdown := "# Agent\n\nComplete the task." + + tmpDir := testutil.TempDir(t, "frontmatter-checkout-tokens-test") + workflowPath := filepath.Join(tmpDir, "test.md") + require.NoError(t, os.WriteFile(workflowPath, []byte(frontmatter+"\n\n"+markdown), 0644)) + + compiler := NewCompiler() + require.NoError(t, compiler.CompileWorkflow(workflowPath)) + + lockFile := strings.TrimSuffix(workflowPath, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockFile) + require.NoError(t, err) + lockStr := string(lockContent) + + // Main checkout (ref: main override) must be present and have persist-credentials: false + assert.Contains(t, lockStr, "name: Checkout repository", "should have main checkout step") + assert.Contains(t, lockStr, "ref: main", "main checkout should use overridden ref") + + // Each additional checkout should carry its token + assert.Contains(t, lockStr, "repository: org/private-a", "should include private-a") + assert.Contains(t, lockStr, "token: ${{ secrets.TOKEN_A }}", "should include TOKEN_A") + assert.Contains(t, lockStr, "repository: org/private-b", "should include private-b") + assert.Contains(t, lockStr, "token: ${{ secrets.TOKEN_B }}", "should include TOKEN_B") + + // Every checkout step must have persist-credentials: false + persistFalseCount := strings.Count(lockStr, "persist-credentials: false") + assert.GreaterOrEqual(t, persistFalseCount, 3, "all checkout steps must have persist-credentials: false") +} + +// TestFrontmatterCheckout_MultipleCheckouts_DifferentFetchDepths verifies that multiple checkouts +// can specify different fetch depths and that the compiled output reflects each independently. +func TestFrontmatterCheckout_MultipleCheckouts_DifferentFetchDepths(t *testing.T) { + frontmatter := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: read +engine: copilot +strict: false +checkout: + - fetch-depth: 0 + - repository: org/large-repo + path: large-repo + fetch-depth: 1 + - repository: org/small-repo + path: small-repo +---` + markdown := "# Agent\n\nComplete the task." + + tmpDir := testutil.TempDir(t, "frontmatter-checkout-depths-test") + workflowPath := filepath.Join(tmpDir, "test.md") + require.NoError(t, os.WriteFile(workflowPath, []byte(frontmatter+"\n\n"+markdown), 0644)) + + compiler := NewCompiler() + require.NoError(t, compiler.CompileWorkflow(workflowPath)) + + lockFile := strings.TrimSuffix(workflowPath, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockFile) + require.NoError(t, err) + lockStr := string(lockContent) + + // Main checkout overrides: fetch-depth 0 (full history) + assert.Contains(t, lockStr, "name: Checkout repository", "should have main checkout step") + assert.Contains(t, lockStr, "fetch-depth: 0", "main checkout should have full history") + + // large-repo: shallow clone depth 1 + assert.Contains(t, lockStr, "repository: org/large-repo", "should include large-repo") + assert.Contains(t, lockStr, "fetch-depth: 1", "large-repo should have shallow clone") + + // small-repo: no fetch-depth (actions/checkout default) + assert.Contains(t, lockStr, "repository: org/small-repo", "should include small-repo") + + // persist-credentials: false must appear for every checkout step + persistFalseCount := strings.Count(lockStr, "persist-credentials: false") + assert.GreaterOrEqual(t, persistFalseCount, 3, "all checkout steps must have persist-credentials: false") +} + +// TestFrontmatterCheckout_PersistCredentialsFalse_Enforced verifies that persist-credentials is +// always false by default even when other fields such as token and fetch-depth are specified. +func TestFrontmatterCheckout_PersistCredentialsFalse_Enforced(t *testing.T) { + frontmatter := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: read +engine: copilot +strict: false +checkout: + - ref: main + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + - repository: org/extra + path: extra + token: ${{ secrets.EXTRA_TOKEN }} + fetch-depth: 10 +---` + markdown := "# Agent\n\nComplete the task." + + tmpDir := testutil.TempDir(t, "frontmatter-checkout-persist-creds-test") + workflowPath := filepath.Join(tmpDir, "test.md") + require.NoError(t, os.WriteFile(workflowPath, []byte(frontmatter+"\n\n"+markdown), 0644)) + + compiler := NewCompiler() + require.NoError(t, compiler.CompileWorkflow(workflowPath)) + + lockFile := strings.TrimSuffix(workflowPath, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockFile) + require.NoError(t, err) + lockStr := string(lockContent) + + // persist-credentials: true must never appear + assert.NotContains(t, lockStr, "persist-credentials: true", "persist-credentials must never be true by default") + + // persist-credentials: false must appear for every checkout step (main + additional) + persistFalseCount := strings.Count(lockStr, "persist-credentials: false") + assert.GreaterOrEqual(t, persistFalseCount, 2, "all checkout steps must have persist-credentials: false") +} From 08916e519b6ab77b9c6d19bb98be4b807723f1e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 06:39:00 +0000 Subject: [PATCH 6/6] Enforce persist-credentials: false unconditionally on all checkout steps - Remove PersistCredentials field from CheckoutConfig (cannot be overridden) - Remove persist-credentials override logic from CheckoutManager - Always emit persist-credentials: false in both main and additional checkout steps - Remove persist-credentials from checkout JSON schema - Update TestCheckoutManager_CustomPersistCredentials to verify always-false behaviour Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schemas/main_workflow_schema.json | 4 ---- pkg/workflow/checkout_manager.go | 12 +++--------- pkg/workflow/checkout_manager_test.go | 10 ++++++---- pkg/workflow/frontmatter_types.go | 2 +- 4 files changed, 10 insertions(+), 18 deletions(-) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 402ab1c454..a8048470f9 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -6795,10 +6795,6 @@ "type": "string", "description": "Relative path under GITHUB_WORKSPACE to place the repository." }, - "persist-credentials": { - "type": "boolean", - "description": "Whether to configure the token or SSH key with the local git config. Default: false." - }, "clean": { "type": "boolean", "description": "Whether to execute git clean -ffdx and git reset --hard HEAD before fetching." diff --git a/pkg/workflow/checkout_manager.go b/pkg/workflow/checkout_manager.go index 75d724d023..d77799b7a1 100644 --- a/pkg/workflow/checkout_manager.go +++ b/pkg/workflow/checkout_manager.go @@ -46,6 +46,7 @@ func (m *CheckoutManager) GenerateMainCheckoutStep() []string { lines = append(lines, " with:\n") // Start from defaults. + // persist-credentials is always false and cannot be overridden by user config. persistCredentials := false // Collect overrides from user-specified first checkout (if it has no explicit path, @@ -77,9 +78,6 @@ func (m *CheckoutManager) GenerateMainCheckoutStep() []string { setSafeDirectory = override.SetSafeDirectory sparseCheckoutConeMode = override.SparseCheckoutConeMode clean = override.Clean - if override.PersistCredentials != nil { - persistCredentials = *override.PersistCredentials - } } // Trial mode overrides: set repository and token when running in trial mode. @@ -217,12 +215,8 @@ func (m *CheckoutManager) generateAdditionalCheckoutStep(co CheckoutConfig, inde } lines = append(lines, fmt.Sprintf(" path: %s\n", path)) - // Default persist-credentials to false for security. - persistCredentials := false - if co.PersistCredentials != nil { - persistCredentials = *co.PersistCredentials - } - lines = append(lines, fmt.Sprintf(" persist-credentials: %v\n", persistCredentials)) + // persist-credentials is always false and cannot be overridden. + lines = append(lines, " persist-credentials: false\n") if co.Clean != nil { lines = append(lines, fmt.Sprintf(" clean: %v\n", *co.Clean)) diff --git a/pkg/workflow/checkout_manager_test.go b/pkg/workflow/checkout_manager_test.go index 3115294a12..de98f69b92 100644 --- a/pkg/workflow/checkout_manager_test.go +++ b/pkg/workflow/checkout_manager_test.go @@ -135,13 +135,15 @@ func TestCheckoutManager_TrialMode(t *testing.T) { assert.Contains(t, result, "token:", "should include token in trial mode") } -func TestCheckoutManager_CustomPersistCredentials(t *testing.T) { - co := CheckoutConfig{PersistCredentials: boolPtr(true)} +func TestCheckoutManager_PersistCredentialsAlwaysFalse(t *testing.T) { + // persist-credentials must always be false; it cannot be overridden by the user. + co := CheckoutConfig{Ref: "my-branch"} mgr := NewCheckoutManager([]CheckoutConfig{co}, false, "") lines := mgr.GenerateMainCheckoutStep() result := strings.Join(lines, "") - assert.Contains(t, result, "persist-credentials: true", "should respect user-specified persist-credentials") + assert.Contains(t, result, "persist-credentials: false", "persist-credentials must always be false") + assert.NotContains(t, result, "persist-credentials: true", "persist-credentials must never be true") } func TestCheckoutManager_FetchDepth(t *testing.T) { @@ -260,7 +262,7 @@ func TestCheckoutManager_MultipleCheckouts_DifferentFetchDepths(t *testing.T) { } // TestCheckoutManager_PersistCredentialsFalseDefault_AllCheckouts verifies that persist-credentials -// defaults to false for every generated checkout step when not explicitly set by the user. +// is always false for every generated checkout step regardless of what the user configures. func TestCheckoutManager_PersistCredentialsFalseDefault_AllCheckouts(t *testing.T) { checkouts := []CheckoutConfig{ // Main checkout override (no path) diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 9ad13075cb..c0578e2a40 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -88,13 +88,13 @@ type RateLimitConfig struct { // CheckoutConfig represents a single actions/checkout configuration. // Supports the same fields as the actions/checkout action. +// Note: persist-credentials is always set to false for security and cannot be overridden. type CheckoutConfig struct { Repository string `json:"repository,omitempty"` // Repository to check out (default: current repo) Ref string `json:"ref,omitempty"` // Branch, tag, or SHA to check out Token string `json:"token,omitempty"` // Personal access token or app token SSHKey string `json:"ssh-key,omitempty"` // SSH key used to fetch the repository Path string `json:"path,omitempty"` // Relative path under GITHUB_WORKSPACE to place the repository - PersistCredentials *bool `json:"persist-credentials,omitempty"` // Whether to persist credentials after checkout (default: false for security) Clean *bool `json:"clean,omitempty"` // Whether to run git clean before fetching Filter string `json:"filter,omitempty"` // Partial clone filter (e.g. "blob:none") SparseCheckout string `json:"sparse-checkout,omitempty"` // List of patterns for sparse checkout