From d25bfd229890306ac4ac40fbad96168db6332bcd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 14:16:09 +0000 Subject: [PATCH 1/9] Initial plan From 860c9feff2039f8057a2dc5720a93b75159aa537 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 14:34:19 +0000 Subject: [PATCH 2/9] fix: reconcile dependabot ignores for compiler-managed action refs Agent-Logs-Url: https://github.com/github/gh-aw/sessions/2a5f9207-7a19-45e6-bf9a-e93f6b895ada Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../docs/reference/compilation-process.md | 10 +- docs/src/content/docs/reference/faq.md | 6 +- pkg/cli/compile_pipeline.go | 18 +++ pkg/workflow/dependabot.go | 126 ++++++++++++++++++ pkg/workflow/dependabot_test.go | 85 ++++++++++++ 5 files changed, 236 insertions(+), 9 deletions(-) diff --git a/docs/src/content/docs/reference/compilation-process.md b/docs/src/content/docs/reference/compilation-process.md index 6aa00885fdf..6a136380a09 100644 --- a/docs/src/content/docs/reference/compilation-process.md +++ b/docs/src/content/docs/reference/compilation-process.md @@ -229,16 +229,16 @@ The repository is referenced via the `--actions-repo` flag default (`github/gh-a Dependabot scans all `.yml` files in `.github/workflows/` for action references and may open pull requests attempting to update `github/gh-aw-actions` to a newer SHA. **Do not merge these PRs.** The correct way to update `gh-aw-actions` pins is by running `gh aw compile` (or `gh aw update-actions`), which regenerates all action pins consistently across all compiled workflows from a single coordinated release. -To suppress Dependabot PRs for `github/gh-aw-actions`, add an `ignore` entry in `.github/dependabot.yml`: +If your repository already has a `github-actions` update block in `.github/dependabot.yml`, `gh aw compile` automatically ensures the compiler-managed ignore rule is present. + +If you are enabling Dependabot for `github-actions`, use a config like: ```yaml updates: - package-ecosystem: github-actions - directory: "/" + directory: "/.github/workflows" ignore: - # ignore updates to gh-aw-actions, which only appears in auto-generated *.lock.yml - # files managed by 'gh aw compile' and should not be touched by dependabot - - dependency-name: "github/gh-aw-actions" + - dependency-name: "github/gh-aw-actions/**" # Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump. ``` This tells Dependabot to skip version updates for `github/gh-aw-actions` while still monitoring all other GitHub Actions dependencies. diff --git a/docs/src/content/docs/reference/faq.md b/docs/src/content/docs/reference/faq.md index 2b289b21f47..bce036e8d21 100644 --- a/docs/src/content/docs/reference/faq.md +++ b/docs/src/content/docs/reference/faq.md @@ -383,11 +383,9 @@ Suppress these PRs by adding an `ignore` entry in `.github/dependabot.yml`: ```yaml updates: - package-ecosystem: github-actions - directory: "/" + directory: "/.github/workflows" ignore: - # ignore updates to gh-aw-actions, which only appears in auto-generated *.lock.yml - # files managed by 'gh aw compile' and should not be touched by dependabot - - dependency-name: "github/gh-aw-actions" + - dependency-name: "github/gh-aw-actions/**" # Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump. ``` See [Dependabot and gh-aw-actions](/gh-aw/reference/compilation-process/#dependabot-and-gh-aw-actions) for more details. diff --git a/pkg/cli/compile_pipeline.go b/pkg/cli/compile_pipeline.go index 20d4f1ce0a1..4cc02c11b6b 100644 --- a/pkg/cli/compile_pipeline.go +++ b/pkg/cli/compile_pipeline.go @@ -453,6 +453,16 @@ func runPostProcessing( } } + // Reconcile compiler-managed Dependabot ignore entries for compiler-emitted action refs. + if !config.NoEmit { + if gitRoot, err := gitutil.FindGitRoot(); err == nil { + dependabotPath := filepath.Join(gitRoot, ".github", "dependabot.yml") + if err := compiler.ReconcileManagedDependabotIgnores(dependabotPath); err != nil && config.Strict { + return err + } + } + } + // Generate maintenance workflow if needed // Only generate when compiling all workflows (not specific files) // Skip when using custom --dir option or when compiling specific files @@ -494,6 +504,14 @@ func runPostProcessingForDirectory( } } + // Reconcile compiler-managed Dependabot ignore entries for compiler-emitted action refs. + if !config.NoEmit { + dependabotPath := filepath.Join(gitRoot, ".github", "dependabot.yml") + if err := compiler.ReconcileManagedDependabotIgnores(dependabotPath); err != nil && config.Strict { + return err + } + } + // Generate maintenance workflow if needed // Skip maintenance workflow generation when using custom --dir option if !config.NoEmit && config.WorkflowDir == "" { diff --git a/pkg/workflow/dependabot.go b/pkg/workflow/dependabot.go index cdf29c95ef9..fa297d8f260 100644 --- a/pkg/workflow/dependabot.go +++ b/pkg/workflow/dependabot.go @@ -3,6 +3,7 @@ package workflow import ( + "bytes" "encoding/json" "errors" "fmt" @@ -15,10 +16,13 @@ import ( "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/logger" "github.com/goccy/go-yaml" + yamlv3 "go.yaml.in/yaml/v3" ) var dependabotLog = logger.New("workflow:dependabot") +const managedDependabotIgnoreComment = "Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump." + // PackageJSON represents the structure of a package.json file type PackageJSON struct { Name string `json:"name"` @@ -412,6 +416,128 @@ func (c *Compiler) generateDependabotConfig(path string, ecosystems map[string]b return nil } +// ReconcileManagedDependabotIgnores updates existing github-actions entries in .github/dependabot.yml +// with compiler-managed ignore rules for compiler-emitted action refs. +// This function is a no-op when dependabot.yml does not exist or has no github-actions update entries. +func (c *Compiler) ReconcileManagedDependabotIgnores(path string) error { + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return nil + } + + original, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read dependabot.yml: %w", err) + } + + var root yamlv3.Node + if err := yamlv3.Unmarshal(original, &root); err != nil { + return fmt.Errorf("failed to parse dependabot.yml: %w", err) + } + + if root.Kind != yamlv3.DocumentNode || len(root.Content) == 0 { + return nil + } + document := root.Content[0] + if document.Kind != yamlv3.MappingNode { + return nil + } + + managedPatterns := []string{fmt.Sprintf("%s/**", c.effectiveActionsRepo())} + updatesNode := getYAMLMapValue(document, "updates") + if updatesNode == nil || updatesNode.Kind != yamlv3.SequenceNode { + return nil + } + + changed := false + for _, updateNode := range updatesNode.Content { + if updateNode.Kind != yamlv3.MappingNode { + continue + } + ecosystemNode := getYAMLMapValue(updateNode, "package-ecosystem") + if ecosystemNode == nil || ecosystemNode.Kind != yamlv3.ScalarNode || ecosystemNode.Value != "github-actions" { + continue + } + + ignoreNode := getYAMLMapValue(updateNode, "ignore") + if ignoreNode == nil { + ignoreNode = &yamlv3.Node{Kind: yamlv3.SequenceNode} + updateNode.Content = append(updateNode.Content, &yamlv3.Node{Kind: yamlv3.ScalarNode, Value: "ignore"}, ignoreNode) + changed = true + } + if ignoreNode.Kind != yamlv3.SequenceNode { + continue + } + + managedPresent := make(map[string]bool, len(managedPatterns)) + for _, ignoreEntryNode := range ignoreNode.Content { + if ignoreEntryNode.Kind != yamlv3.MappingNode { + continue + } + dependencyNameNode := getYAMLMapValue(ignoreEntryNode, "dependency-name") + if dependencyNameNode == nil || dependencyNameNode.Kind != yamlv3.ScalarNode { + continue + } + for _, pattern := range managedPatterns { + if dependencyNameNode.Value == pattern { + managedPresent[pattern] = true + if dependencyNameNode.LineComment != managedDependabotIgnoreComment { + dependencyNameNode.LineComment = managedDependabotIgnoreComment + changed = true + } + } + } + } + + for _, pattern := range managedPatterns { + if managedPresent[pattern] { + continue + } + ignoreNode.Content = append(ignoreNode.Content, &yamlv3.Node{ + Kind: yamlv3.MappingNode, + Content: []*yamlv3.Node{ + {Kind: yamlv3.ScalarNode, Value: "dependency-name"}, + { + Kind: yamlv3.ScalarNode, + Value: pattern, + Style: yamlv3.DoubleQuotedStyle, + LineComment: managedDependabotIgnoreComment, + }, + }, + }) + changed = true + } + } + + if !changed { + return nil + } + + updated, err := yamlv3.Marshal(&root) + if err != nil { + return fmt.Errorf("failed to encode dependabot.yml: %w", err) + } + + if bytes.Equal(original, updated) { + return nil + } + if err := os.WriteFile(path, updated, 0644); err != nil { + return fmt.Errorf("failed to write dependabot.yml: %w", err) + } + return nil +} + +func getYAMLMapValue(mappingNode *yamlv3.Node, key string) *yamlv3.Node { + if mappingNode == nil || mappingNode.Kind != yamlv3.MappingNode { + return nil + } + for i := 0; i+1 < len(mappingNode.Content); i += 2 { + if mappingNode.Content[i].Value == key { + return mappingNode.Content[i+1] + } + } + return nil +} + // collectPipDependencies collects all pip dependencies from workflow data func (c *Compiler) collectPipDependencies(workflowDataList []*WorkflowData) []PipDependency { dependabotLog.Print("Collecting pip dependencies from workflows") diff --git a/pkg/workflow/dependabot_test.go b/pkg/workflow/dependabot_test.go index cee416e60fa..c99cbe751bf 100644 --- a/pkg/workflow/dependabot_test.go +++ b/pkg/workflow/dependabot_test.go @@ -344,6 +344,91 @@ func TestGenerateDependabotConfig_PreserveExisting(t *testing.T) { } } +func TestReconcileManagedDependabotIgnores_NoDependabotFile(t *testing.T) { + compiler := NewCompiler() + tempDir := testutil.TempDir(t, "test-*") + dependabotPath := filepath.Join(tempDir, "dependabot.yml") + + err := compiler.ReconcileManagedDependabotIgnores(dependabotPath) + if err != nil { + t.Fatalf("expected no error when dependabot.yml is missing, got: %v", err) + } + + if _, statErr := os.Stat(dependabotPath); !os.IsNotExist(statErr) { + t.Fatal("dependabot.yml should not be created when missing") + } +} + +func TestReconcileManagedDependabotIgnores_NoGitHubActionsUpdate(t *testing.T) { + compiler := NewCompiler() + tempDir := testutil.TempDir(t, "test-*") + dependabotPath := filepath.Join(tempDir, "dependabot.yml") + + original := `version: 2 +updates: + - package-ecosystem: npm + directory: "/.github/workflows" + schedule: + interval: weekly +` + if err := os.WriteFile(dependabotPath, []byte(original), 0644); err != nil { + t.Fatalf("failed to write test dependabot.yml: %v", err) + } + + err := compiler.ReconcileManagedDependabotIgnores(dependabotPath) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + updated, err := os.ReadFile(dependabotPath) + if err != nil { + t.Fatalf("failed to read updated dependabot.yml: %v", err) + } + if string(updated) != original { + t.Fatal("dependabot.yml should be unchanged when github-actions updates are absent") + } +} + +func TestReconcileManagedDependabotIgnores_AddsManagedEntry(t *testing.T) { + compiler := NewCompiler() + tempDir := testutil.TempDir(t, "test-*") + dependabotPath := filepath.Join(tempDir, "dependabot.yml") + + original := `version: 2 +updates: + - package-ecosystem: github-actions + directory: "/.github/workflows" + schedule: + interval: weekly + ignore: + - dependency-name: "actions/checkout" +` + if err := os.WriteFile(dependabotPath, []byte(original), 0644); err != nil { + t.Fatalf("failed to write test dependabot.yml: %v", err) + } + + err := compiler.ReconcileManagedDependabotIgnores(dependabotPath) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + updated, err := os.ReadFile(dependabotPath) + if err != nil { + t.Fatalf("failed to read updated dependabot.yml: %v", err) + } + + updatedStr := string(updated) + if !strings.Contains(updatedStr, `dependency-name: "actions/checkout"`) { + t.Fatal("user-defined ignore entry should be preserved") + } + if !strings.Contains(updatedStr, `dependency-name: "github/gh-aw-actions/**"`) { + t.Fatal("managed github/gh-aw-actions ignore entry should be added") + } + if !strings.Contains(updatedStr, managedDependabotIgnoreComment) { + t.Fatal("managed ignore entry should include the compiler-managed inline comment") + } +} + func TestGenerateDependabotManifests_NoDependencies(t *testing.T) { compiler := NewCompiler() tempDir := testutil.TempDir(t, "test-*") From b9c215a9f0f8ec76c8b121d97fcebca335ca2419 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 17:51:23 +0000 Subject: [PATCH 3/9] fix: handle null dependabot ignore and warn on reconcile failures Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b2b8b2e2-9d67-41c4-80e8-2b4927a465f7 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/compile_pipeline.go | 14 +++++++++---- pkg/workflow/dependabot.go | 18 +++++++++++++++++ pkg/workflow/dependabot_test.go | 36 +++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/pkg/cli/compile_pipeline.go b/pkg/cli/compile_pipeline.go index 4cc02c11b6b..ba38ebb8024 100644 --- a/pkg/cli/compile_pipeline.go +++ b/pkg/cli/compile_pipeline.go @@ -457,8 +457,11 @@ func runPostProcessing( if !config.NoEmit { if gitRoot, err := gitutil.FindGitRoot(); err == nil { dependabotPath := filepath.Join(gitRoot, ".github", "dependabot.yml") - if err := compiler.ReconcileManagedDependabotIgnores(dependabotPath); err != nil && config.Strict { - return err + if err := compiler.ReconcileManagedDependabotIgnores(dependabotPath); err != nil { + if config.Strict { + return err + } + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to reconcile compiler-managed Dependabot ignore entries: %v", err))) } } } @@ -507,8 +510,11 @@ func runPostProcessingForDirectory( // Reconcile compiler-managed Dependabot ignore entries for compiler-emitted action refs. if !config.NoEmit { dependabotPath := filepath.Join(gitRoot, ".github", "dependabot.yml") - if err := compiler.ReconcileManagedDependabotIgnores(dependabotPath); err != nil && config.Strict { - return err + if err := compiler.ReconcileManagedDependabotIgnores(dependabotPath); err != nil { + if config.Strict { + return err + } + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to reconcile compiler-managed Dependabot ignore entries: %v", err))) } } diff --git a/pkg/workflow/dependabot.go b/pkg/workflow/dependabot.go index fa297d8f260..130338dd03e 100644 --- a/pkg/workflow/dependabot.go +++ b/pkg/workflow/dependabot.go @@ -464,6 +464,13 @@ func (c *Compiler) ReconcileManagedDependabotIgnores(path string) error { updateNode.Content = append(updateNode.Content, &yamlv3.Node{Kind: yamlv3.ScalarNode, Value: "ignore"}, ignoreNode) changed = true } + if isYAMLNullOrEmptyScalar(ignoreNode) { + ignoreNode.Kind = yamlv3.SequenceNode + ignoreNode.Tag = "!!seq" + ignoreNode.Value = "" + ignoreNode.Content = nil + changed = true + } if ignoreNode.Kind != yamlv3.SequenceNode { continue } @@ -538,6 +545,17 @@ func getYAMLMapValue(mappingNode *yamlv3.Node, key string) *yamlv3.Node { return nil } +func isYAMLNullOrEmptyScalar(node *yamlv3.Node) bool { + if node == nil || node.Kind != yamlv3.ScalarNode { + return false + } + if node.Tag == "!!null" { + return true + } + value := strings.TrimSpace(node.Value) + return value == "" || strings.EqualFold(value, "null") || value == "~" +} + // collectPipDependencies collects all pip dependencies from workflow data func (c *Compiler) collectPipDependencies(workflowDataList []*WorkflowData) []PipDependency { dependabotLog.Print("Collecting pip dependencies from workflows") diff --git a/pkg/workflow/dependabot_test.go b/pkg/workflow/dependabot_test.go index c99cbe751bf..ccdf73c98a9 100644 --- a/pkg/workflow/dependabot_test.go +++ b/pkg/workflow/dependabot_test.go @@ -429,6 +429,42 @@ updates: } } +func TestReconcileManagedDependabotIgnores_ReplacesNullIgnoreWithManagedEntry(t *testing.T) { + compiler := NewCompiler() + tempDir := testutil.TempDir(t, "test-*") + dependabotPath := filepath.Join(tempDir, "dependabot.yml") + + original := `version: 2 +updates: + - package-ecosystem: github-actions + directory: "/.github/workflows" + schedule: + interval: weekly + ignore: +` + if err := os.WriteFile(dependabotPath, []byte(original), 0644); err != nil { + t.Fatalf("failed to write test dependabot.yml: %v", err) + } + + err := compiler.ReconcileManagedDependabotIgnores(dependabotPath) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + updated, err := os.ReadFile(dependabotPath) + if err != nil { + t.Fatalf("failed to read updated dependabot.yml: %v", err) + } + + updatedStr := string(updated) + if !strings.Contains(updatedStr, "ignore:") { + t.Fatal("ignore block should still be present") + } + if !strings.Contains(updatedStr, `dependency-name: "github/gh-aw-actions/**"`) { + t.Fatal("managed github/gh-aw-actions ignore entry should be added when ignore is null") + } +} + func TestGenerateDependabotManifests_NoDependencies(t *testing.T) { compiler := NewCompiler() tempDir := testutil.TempDir(t, "test-*") From ed2187f929b1f6b74e50d8a4218dff396963bd50 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 17:54:41 +0000 Subject: [PATCH 4/9] chore: document yaml null forms in dependabot ignore parser Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b2b8b2e2-9d67-41c4-80e8-2b4927a465f7 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/dependabot.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/workflow/dependabot.go b/pkg/workflow/dependabot.go index 130338dd03e..597e280c43b 100644 --- a/pkg/workflow/dependabot.go +++ b/pkg/workflow/dependabot.go @@ -549,6 +549,8 @@ func isYAMLNullOrEmptyScalar(node *yamlv3.Node) bool { if node == nil || node.Kind != yamlv3.ScalarNode { return false } + // YAML null may be represented as an explicit !!null tag, an empty scalar + // (`ignore:`), the canonical `null` token (case-insensitive), or `~`. if node.Tag == "!!null" { return true } From e2e7de54af0c0b4a5a4e6c3c0f41da8253014e6c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 20:55:39 +0000 Subject: [PATCH 5/9] refactor: centralize dependabot path resolution and yaml handling Agent-Logs-Url: https://github.com/github/gh-aw/sessions/6a125e1c-d070-485b-8109-74ba12701041 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/compile_pipeline.go | 6 +- pkg/workflow/dependabot.go | 212 ++++++++++++++++++++++---------- pkg/workflow/dependabot_test.go | 46 +++++++ 3 files changed, 198 insertions(+), 66 deletions(-) diff --git a/pkg/cli/compile_pipeline.go b/pkg/cli/compile_pipeline.go index ba38ebb8024..23e5dc72a10 100644 --- a/pkg/cli/compile_pipeline.go +++ b/pkg/cli/compile_pipeline.go @@ -456,8 +456,7 @@ func runPostProcessing( // Reconcile compiler-managed Dependabot ignore entries for compiler-emitted action refs. if !config.NoEmit { if gitRoot, err := gitutil.FindGitRoot(); err == nil { - dependabotPath := filepath.Join(gitRoot, ".github", "dependabot.yml") - if err := compiler.ReconcileManagedDependabotIgnores(dependabotPath); err != nil { + if err := compiler.ReconcileManagedDependabotIgnoresInRepo(gitRoot); err != nil { if config.Strict { return err } @@ -509,8 +508,7 @@ func runPostProcessingForDirectory( // Reconcile compiler-managed Dependabot ignore entries for compiler-emitted action refs. if !config.NoEmit { - dependabotPath := filepath.Join(gitRoot, ".github", "dependabot.yml") - if err := compiler.ReconcileManagedDependabotIgnores(dependabotPath); err != nil { + if err := compiler.ReconcileManagedDependabotIgnoresInRepo(gitRoot); err != nil { if config.Strict { return err } diff --git a/pkg/workflow/dependabot.go b/pkg/workflow/dependabot.go index 597e280c43b..5dfacd1efee 100644 --- a/pkg/workflow/dependabot.go +++ b/pkg/workflow/dependabot.go @@ -10,19 +10,21 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "sort" "strings" "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/logger" "github.com/goccy/go-yaml" - yamlv3 "go.yaml.in/yaml/v3" ) var dependabotLog = logger.New("workflow:dependabot") const managedDependabotIgnoreComment = "Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump." +const dependabotConfigRelativePath = ".github/dependabot.yml" + // PackageJSON represents the structure of a package.json file type PackageJSON struct { Name string `json:"name"` @@ -429,66 +431,62 @@ func (c *Compiler) ReconcileManagedDependabotIgnores(path string) error { return fmt.Errorf("failed to read dependabot.yml: %w", err) } - var root yamlv3.Node - if err := yamlv3.Unmarshal(original, &root); err != nil { + var root map[string]any + if err := yaml.Unmarshal(original, &root); err != nil { return fmt.Errorf("failed to parse dependabot.yml: %w", err) } - if root.Kind != yamlv3.DocumentNode || len(root.Content) == 0 { + updatesAny, ok := root["updates"] + if !ok { return nil } - document := root.Content[0] - if document.Kind != yamlv3.MappingNode { + updates, ok := dependabotToAnySlice(updatesAny) + if !ok { return nil } managedPatterns := []string{fmt.Sprintf("%s/**", c.effectiveActionsRepo())} - updatesNode := getYAMLMapValue(document, "updates") - if updatesNode == nil || updatesNode.Kind != yamlv3.SequenceNode { - return nil - } - changed := false - for _, updateNode := range updatesNode.Content { - if updateNode.Kind != yamlv3.MappingNode { + originalStr := string(original) + + for i, updateAny := range updates { + updateMap, ok := dependabotToStringAnyMap(updateAny) + if !ok { continue } - ecosystemNode := getYAMLMapValue(updateNode, "package-ecosystem") - if ecosystemNode == nil || ecosystemNode.Kind != yamlv3.ScalarNode || ecosystemNode.Value != "github-actions" { + + ecosystem, _ := updateMap["package-ecosystem"].(string) + if ecosystem != "github-actions" { continue } - ignoreNode := getYAMLMapValue(updateNode, "ignore") - if ignoreNode == nil { - ignoreNode = &yamlv3.Node{Kind: yamlv3.SequenceNode} - updateNode.Content = append(updateNode.Content, &yamlv3.Node{Kind: yamlv3.ScalarNode, Value: "ignore"}, ignoreNode) - changed = true - } - if isYAMLNullOrEmptyScalar(ignoreNode) { - ignoreNode.Kind = yamlv3.SequenceNode - ignoreNode.Tag = "!!seq" - ignoreNode.Value = "" - ignoreNode.Content = nil + ignoreAny, hasIgnore := updateMap["ignore"] + if !hasIgnore || isYAMLNullOrEmptyScalar(ignoreAny) { + updateMap["ignore"] = []any{} + ignoreAny = updateMap["ignore"] changed = true } - if ignoreNode.Kind != yamlv3.SequenceNode { + + ignoreEntries, ok := dependabotToAnySlice(ignoreAny) + if !ok { continue } managedPresent := make(map[string]bool, len(managedPatterns)) - for _, ignoreEntryNode := range ignoreNode.Content { - if ignoreEntryNode.Kind != yamlv3.MappingNode { + for _, ignoreEntryAny := range ignoreEntries { + ignoreEntryMap, ok := dependabotToStringAnyMap(ignoreEntryAny) + if !ok { continue } - dependencyNameNode := getYAMLMapValue(ignoreEntryNode, "dependency-name") - if dependencyNameNode == nil || dependencyNameNode.Kind != yamlv3.ScalarNode { + dependencyName, _ := ignoreEntryMap["dependency-name"].(string) + if dependencyName == "" { continue } + for _, pattern := range managedPatterns { - if dependencyNameNode.Value == pattern { + if dependencyName == pattern { managedPresent[pattern] = true - if dependencyNameNode.LineComment != managedDependabotIgnoreComment { - dependencyNameNode.LineComment = managedDependabotIgnoreComment + if !hasManagedIgnoreComment(originalStr, pattern) { changed = true } } @@ -499,30 +497,24 @@ func (c *Compiler) ReconcileManagedDependabotIgnores(path string) error { if managedPresent[pattern] { continue } - ignoreNode.Content = append(ignoreNode.Content, &yamlv3.Node{ - Kind: yamlv3.MappingNode, - Content: []*yamlv3.Node{ - {Kind: yamlv3.ScalarNode, Value: "dependency-name"}, - { - Kind: yamlv3.ScalarNode, - Value: pattern, - Style: yamlv3.DoubleQuotedStyle, - LineComment: managedDependabotIgnoreComment, - }, - }, - }) + ignoreEntries = append(ignoreEntries, map[string]any{"dependency-name": pattern}) changed = true } + + updateMap["ignore"] = ignoreEntries + updates[i] = updateMap } if !changed { return nil } - updated, err := yamlv3.Marshal(&root) + root["updates"] = updates + updated, err := yaml.Marshal(root) if err != nil { return fmt.Errorf("failed to encode dependabot.yml: %w", err) } + updated = normalizeDependabotIgnoreEntries(updated, managedPatterns) if bytes.Equal(original, updated) { return nil @@ -533,29 +525,125 @@ func (c *Compiler) ReconcileManagedDependabotIgnores(path string) error { return nil } -func getYAMLMapValue(mappingNode *yamlv3.Node, key string) *yamlv3.Node { - if mappingNode == nil || mappingNode.Kind != yamlv3.MappingNode { - return nil +// DependabotConfigPath resolves the repository-local Dependabot config path. +func DependabotConfigPath(gitRoot string) string { + return filepath.Join(gitRoot, dependabotConfigRelativePath) +} + +// ReconcileManagedDependabotIgnoresInRepo reconciles managed ignores in the +// Dependabot config located under a repository root. +func (c *Compiler) ReconcileManagedDependabotIgnoresInRepo(gitRoot string) error { + return c.ReconcileManagedDependabotIgnores(DependabotConfigPath(gitRoot)) +} + +func dependabotToAnySlice(value any) ([]any, bool) { + if value == nil { + return nil, false + } + if direct, ok := value.([]any); ok { + return direct, true + } + + rv := reflect.ValueOf(value) + if rv.Kind() != reflect.Slice { + return nil, false + } + + out := make([]any, rv.Len()) + for i := 0; i < rv.Len(); i++ { + out[i] = rv.Index(i).Interface() } - for i := 0; i+1 < len(mappingNode.Content); i += 2 { - if mappingNode.Content[i].Value == key { - return mappingNode.Content[i+1] + return out, true +} + +func dependabotToStringAnyMap(value any) (map[string]any, bool) { + if value == nil { + return nil, false + } + if direct, ok := value.(map[string]any); ok { + return direct, true + } + + rv := reflect.ValueOf(value) + if rv.Kind() != reflect.Map { + return nil, false + } + + out := make(map[string]any, rv.Len()) + iter := rv.MapRange() + for iter.Next() { + key, ok := iter.Key().Interface().(string) + if !ok { + return nil, false } + out[key] = iter.Value().Interface() } - return nil + return out, true } -func isYAMLNullOrEmptyScalar(node *yamlv3.Node) bool { - if node == nil || node.Kind != yamlv3.ScalarNode { +func isYAMLNullOrEmptyScalar(value any) bool { + if value == nil { + return true + } + rawValue, ok := value.(string) + if !ok { return false } - // YAML null may be represented as an explicit !!null tag, an empty scalar - // (`ignore:`), the canonical `null` token (case-insensitive), or `~`. - if node.Tag == "!!null" { - return true + trimmed := strings.TrimSpace(rawValue) + return trimmed == "" || strings.EqualFold(trimmed, "null") || trimmed == "~" +} + +func hasManagedIgnoreComment(content string, pattern string) bool { + for _, line := range strings.Split(content, "\n") { + if strings.Contains(line, "dependency-name:") && + strings.Contains(line, pattern) && + strings.Contains(line, managedDependabotIgnoreComment) { + return true + } + } + return false +} + +func normalizeDependabotIgnoreEntries(content []byte, managedPatterns []string) []byte { + lines := strings.Split(string(content), "\n") + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if !strings.Contains(trimmed, "dependency-name:") { + continue + } + + beforeComment, comment, hasComment := strings.Cut(line, "#") + parts := strings.SplitN(beforeComment, "dependency-name:", 2) + if len(parts) != 2 { + continue + } + + prefix := parts[0] + "dependency-name: " + dependencyName := strings.TrimSpace(parts[1]) + dependencyName = strings.Trim(dependencyName, `"'`) + if dependencyName == "" { + continue + } + + line = prefix + `"` + dependencyName + `"` + + managed := false + for _, pattern := range managedPatterns { + if dependencyName == pattern { + managed = true + break + } + } + + if managed { + line += " # " + managedDependabotIgnoreComment + } else if hasComment { + line += " #" + strings.TrimSpace(comment) + } + + lines[i] = line } - value := strings.TrimSpace(node.Value) - return value == "" || strings.EqualFold(value, "null") || value == "~" + return []byte(strings.Join(lines, "\n")) } // collectPipDependencies collects all pip dependencies from workflow data diff --git a/pkg/workflow/dependabot_test.go b/pkg/workflow/dependabot_test.go index ccdf73c98a9..6f6e20e4be0 100644 --- a/pkg/workflow/dependabot_test.go +++ b/pkg/workflow/dependabot_test.go @@ -359,6 +359,52 @@ func TestReconcileManagedDependabotIgnores_NoDependabotFile(t *testing.T) { } } +func TestDependabotConfigPath(t *testing.T) { + root := "/tmp/example-repo" + expected := filepath.Join(root, ".github", "dependabot.yml") + if actual := DependabotConfigPath(root); actual != expected { + t.Fatalf("expected dependabot path %q, got %q", expected, actual) + } +} + +func TestReconcileManagedDependabotIgnoresInRepo(t *testing.T) { + compiler := NewCompiler() + tempDir := testutil.TempDir(t, "test-*") + githubDir := filepath.Join(tempDir, ".github") + if err := os.MkdirAll(githubDir, 0755); err != nil { + t.Fatalf("failed to create .github directory: %v", err) + } + + dependabotPath := filepath.Join(githubDir, "dependabot.yml") + original := `version: 2 +updates: + - package-ecosystem: github-actions + directory: "/.github/workflows" + schedule: + interval: weekly + ignore: + - dependency-name: "actions/checkout" +` + if err := os.WriteFile(dependabotPath, []byte(original), 0644); err != nil { + t.Fatalf("failed to write test dependabot.yml: %v", err) + } + + err := compiler.ReconcileManagedDependabotIgnoresInRepo(tempDir) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + updated, err := os.ReadFile(dependabotPath) + if err != nil { + t.Fatalf("failed to read updated dependabot.yml: %v", err) + } + + updatedStr := string(updated) + if !strings.Contains(updatedStr, `dependency-name: "github/gh-aw-actions/**"`) { + t.Fatal("managed github/gh-aw-actions ignore entry should be added") + } +} + func TestReconcileManagedDependabotIgnores_NoGitHubActionsUpdate(t *testing.T) { compiler := NewCompiler() tempDir := testutil.TempDir(t, "test-*") From 9e784ef0bd84772db67c290515b745e908e843cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 20:58:53 +0000 Subject: [PATCH 6/9] perf: streamline managed dependabot comment detection Agent-Logs-Url: https://github.com/github/gh-aw/sessions/6a125e1c-d070-485b-8109-74ba12701041 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/dependabot.go | 22 +++++++++++++--------- pkg/workflow/dependabot_test.go | 2 +- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/pkg/workflow/dependabot.go b/pkg/workflow/dependabot.go index 5dfacd1efee..bb775475a84 100644 --- a/pkg/workflow/dependabot.go +++ b/pkg/workflow/dependabot.go @@ -448,6 +448,7 @@ func (c *Compiler) ReconcileManagedDependabotIgnores(path string) error { managedPatterns := []string{fmt.Sprintf("%s/**", c.effectiveActionsRepo())} changed := false originalStr := string(original) + managedPatternsWithComment := managedPatternsWithInlineComment(originalStr, managedPatterns) for i, updateAny := range updates { updateMap, ok := dependabotToStringAnyMap(updateAny) @@ -486,7 +487,7 @@ func (c *Compiler) ReconcileManagedDependabotIgnores(path string) error { for _, pattern := range managedPatterns { if dependencyName == pattern { managedPresent[pattern] = true - if !hasManagedIgnoreComment(originalStr, pattern) { + if !managedPatternsWithComment[pattern] { changed = true } } @@ -593,22 +594,25 @@ func isYAMLNullOrEmptyScalar(value any) bool { return trimmed == "" || strings.EqualFold(trimmed, "null") || trimmed == "~" } -func hasManagedIgnoreComment(content string, pattern string) bool { +func managedPatternsWithInlineComment(content string, managedPatterns []string) map[string]bool { + result := make(map[string]bool, len(managedPatterns)) for _, line := range strings.Split(content, "\n") { - if strings.Contains(line, "dependency-name:") && - strings.Contains(line, pattern) && - strings.Contains(line, managedDependabotIgnoreComment) { - return true + if !strings.Contains(line, "dependency-name:") || !strings.Contains(line, managedDependabotIgnoreComment) { + continue + } + for _, pattern := range managedPatterns { + if strings.Contains(line, pattern) { + result[pattern] = true + } } } - return false + return result } func normalizeDependabotIgnoreEntries(content []byte, managedPatterns []string) []byte { lines := strings.Split(string(content), "\n") for i, line := range lines { - trimmed := strings.TrimSpace(line) - if !strings.Contains(trimmed, "dependency-name:") { + if !strings.Contains(line, "dependency-name:") { continue } diff --git a/pkg/workflow/dependabot_test.go b/pkg/workflow/dependabot_test.go index 6f6e20e4be0..6328b2e10df 100644 --- a/pkg/workflow/dependabot_test.go +++ b/pkg/workflow/dependabot_test.go @@ -360,7 +360,7 @@ func TestReconcileManagedDependabotIgnores_NoDependabotFile(t *testing.T) { } func TestDependabotConfigPath(t *testing.T) { - root := "/tmp/example-repo" + root := "/path/to/repo" expected := filepath.Join(root, ".github", "dependabot.yml") if actual := DependabotConfigPath(root); actual != expected { t.Fatalf("expected dependabot path %q, got %q", expected, actual) From 000d52a5c62fbc3ccd5eb7e40a28b75dfb9ebe0b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 21:02:02 +0000 Subject: [PATCH 7/9] chore: document reflection fallbacks in dependabot yaml normalization Agent-Logs-Url: https://github.com/github/gh-aw/sessions/6a125e1c-d070-485b-8109-74ba12701041 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/dependabot.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/pkg/workflow/dependabot.go b/pkg/workflow/dependabot.go index bb775475a84..663ffe0fef1 100644 --- a/pkg/workflow/dependabot.go +++ b/pkg/workflow/dependabot.go @@ -545,10 +545,13 @@ func dependabotToAnySlice(value any) ([]any, bool) { return direct, true } + // goccy/go-yaml can decode typed slices depending on source shape. + // Use reflection fallback to safely normalize those typed slices to []any. rv := reflect.ValueOf(value) if rv.Kind() != reflect.Slice { return nil, false } + dependabotLog.Printf("Normalizing typed slice %T to []any via reflection", value) out := make([]any, rv.Len()) for i := 0; i < rv.Len(); i++ { @@ -565,10 +568,13 @@ func dependabotToStringAnyMap(value any) (map[string]any, bool) { return direct, true } + // goccy/go-yaml can decode typed maps in dynamic sections. + // Use reflection fallback to safely normalize those maps to map[string]any. rv := reflect.ValueOf(value) if rv.Kind() != reflect.Map { return nil, false } + dependabotLog.Printf("Normalizing typed map %T to map[string]any via reflection", value) out := make(map[string]any, rv.Len()) iter := rv.MapRange() @@ -623,13 +629,17 @@ func normalizeDependabotIgnoreEntries(content []byte, managedPatterns []string) } prefix := parts[0] + "dependency-name: " - dependencyName := strings.TrimSpace(parts[1]) - dependencyName = strings.Trim(dependencyName, `"'`) + rawDependencyName := strings.TrimSpace(parts[1]) + quote := `"` + if strings.HasPrefix(rawDependencyName, "'") && strings.HasSuffix(rawDependencyName, "'") { + quote = `'` + } + dependencyName := strings.Trim(rawDependencyName, `"'`) if dependencyName == "" { continue } - line = prefix + `"` + dependencyName + `"` + line = prefix + quote + dependencyName + quote managed := false for _, pattern := range managedPatterns { From 75a38c109598954dcd4de18431ed6af27717ac9d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 21:05:01 +0000 Subject: [PATCH 8/9] chore: clarify reflection fallback logs and quote assumptions Agent-Logs-Url: https://github.com/github/gh-aw/sessions/6a125e1c-d070-485b-8109-74ba12701041 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/dependabot.go | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/pkg/workflow/dependabot.go b/pkg/workflow/dependabot.go index 663ffe0fef1..3ecba487a0b 100644 --- a/pkg/workflow/dependabot.go +++ b/pkg/workflow/dependabot.go @@ -551,7 +551,10 @@ func dependabotToAnySlice(value any) ([]any, bool) { if rv.Kind() != reflect.Slice { return nil, false } - dependabotLog.Printf("Normalizing typed slice %T to []any via reflection", value) + dependabotLog.Printf( + "Normalizing typed slice %T to []any via reflection (goccy/go-yaml may return typed slices depending on YAML structure)", + value, + ) out := make([]any, rv.Len()) for i := 0; i < rv.Len(); i++ { @@ -574,7 +577,10 @@ func dependabotToStringAnyMap(value any) (map[string]any, bool) { if rv.Kind() != reflect.Map { return nil, false } - dependabotLog.Printf("Normalizing typed map %T to map[string]any via reflection", value) + dependabotLog.Printf( + "Normalizing typed map %T to map[string]any via reflection (goccy/go-yaml may return typed maps in dynamic sections)", + value, + ) out := make(map[string]any, rv.Len()) iter := rv.MapRange() @@ -631,8 +637,15 @@ func normalizeDependabotIgnoreEntries(content []byte, managedPatterns []string) prefix := parts[0] + "dependency-name: " rawDependencyName := strings.TrimSpace(parts[1]) quote := `"` - if strings.HasPrefix(rawDependencyName, "'") && strings.HasSuffix(rawDependencyName, "'") { + // Assume quote characters are balanced when present. If the scalar starts + // with a quote but does not end with the same quote, skip normalization. + if strings.HasPrefix(rawDependencyName, "'") { + if !strings.HasSuffix(rawDependencyName, "'") { + continue + } quote = `'` + } else if strings.HasPrefix(rawDependencyName, `"`) && !strings.HasSuffix(rawDependencyName, `"`) { + continue } dependencyName := strings.Trim(rawDependencyName, `"'`) if dependencyName == "" { From b55b64015adc1c11d22849971ab0beec9c328675 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 21:13:14 +0000 Subject: [PATCH 9/9] docs: clarify compiler-managed dependabot ignore reconciliation Agent-Logs-Url: https://github.com/github/gh-aw/sessions/868fb3ed-0a16-466d-94b0-c718c1ef2582 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/src/content/docs/reference/dependabot.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/src/content/docs/reference/dependabot.md b/docs/src/content/docs/reference/dependabot.md index b79fae046f1..ea6e13e5433 100644 --- a/docs/src/content/docs/reference/dependabot.md +++ b/docs/src/content/docs/reference/dependabot.md @@ -16,6 +16,25 @@ Run `gh aw compile --dependabot` to compile all workflows and generate manifests **Prerequisites**: Node.js/npm required for `package-lock.json` generation. Pip and Go manifests generate without additional tools. +## Compiler-managed `gh-aw-actions` ignore rule + +`gh aw compile` always reconciles the compiler-managed ignore rule for `github/gh-aw-actions/**` when your repository already has a `github-actions` update block in `.github/dependabot.yml` (this is not limited to `--dependabot` runs). + +- No-op if `.github/dependabot.yml` does not exist +- No-op if there is no `package-ecosystem: github-actions` update block +- Preserves user-defined `ignore` entries + +```yaml +updates: + - package-ecosystem: github-actions + directory: "/.github/workflows" + schedule: + interval: weekly + ignore: + - dependency-name: "github/gh-aw-actions/**" # Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump. + - dependency-name: "actions/checkout" # user-defined, preserved +``` + ## Generated Files | Ecosystem | Manifest | Lock File |