diff --git a/docs/src/content/docs/reference/compilation-process.md b/docs/src/content/docs/reference/compilation-process.md index 6aa00885fd..6a136380a0 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/dependabot.md b/docs/src/content/docs/reference/dependabot.md index b79fae046f..ea6e13e543 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 | diff --git a/docs/src/content/docs/reference/faq.md b/docs/src/content/docs/reference/faq.md index 168b8b559d..e6ec0a47c6 100644 --- a/docs/src/content/docs/reference/faq.md +++ b/docs/src/content/docs/reference/faq.md @@ -389,11 +389,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 20d4f1ce0a..23e5dc72a1 100644 --- a/pkg/cli/compile_pipeline.go +++ b/pkg/cli/compile_pipeline.go @@ -453,6 +453,18 @@ func runPostProcessing( } } + // Reconcile compiler-managed Dependabot ignore entries for compiler-emitted action refs. + if !config.NoEmit { + if gitRoot, err := gitutil.FindGitRoot(); err == nil { + if err := compiler.ReconcileManagedDependabotIgnoresInRepo(gitRoot); 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))) + } + } + } + // 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 +506,16 @@ func runPostProcessingForDirectory( } } + // Reconcile compiler-managed Dependabot ignore entries for compiler-emitted action refs. + if !config.NoEmit { + if err := compiler.ReconcileManagedDependabotIgnoresInRepo(gitRoot); 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))) + } + } + // 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 cdf29c95ef..3ecba487a0 100644 --- a/pkg/workflow/dependabot.go +++ b/pkg/workflow/dependabot.go @@ -3,12 +3,14 @@ package workflow import ( + "bytes" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" + "reflect" "sort" "strings" @@ -19,6 +21,10 @@ import ( 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"` @@ -412,6 +418,261 @@ 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 map[string]any + if err := yaml.Unmarshal(original, &root); err != nil { + return fmt.Errorf("failed to parse dependabot.yml: %w", err) + } + + updatesAny, ok := root["updates"] + if !ok { + return nil + } + updates, ok := dependabotToAnySlice(updatesAny) + if !ok { + return nil + } + + 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) + if !ok { + continue + } + + ecosystem, _ := updateMap["package-ecosystem"].(string) + if ecosystem != "github-actions" { + continue + } + + ignoreAny, hasIgnore := updateMap["ignore"] + if !hasIgnore || isYAMLNullOrEmptyScalar(ignoreAny) { + updateMap["ignore"] = []any{} + ignoreAny = updateMap["ignore"] + changed = true + } + + ignoreEntries, ok := dependabotToAnySlice(ignoreAny) + if !ok { + continue + } + + managedPresent := make(map[string]bool, len(managedPatterns)) + for _, ignoreEntryAny := range ignoreEntries { + ignoreEntryMap, ok := dependabotToStringAnyMap(ignoreEntryAny) + if !ok { + continue + } + dependencyName, _ := ignoreEntryMap["dependency-name"].(string) + if dependencyName == "" { + continue + } + + for _, pattern := range managedPatterns { + if dependencyName == pattern { + managedPresent[pattern] = true + if !managedPatternsWithComment[pattern] { + changed = true + } + } + } + } + + for _, pattern := range managedPatterns { + if managedPresent[pattern] { + continue + } + ignoreEntries = append(ignoreEntries, map[string]any{"dependency-name": pattern}) + changed = true + } + + updateMap["ignore"] = ignoreEntries + updates[i] = updateMap + } + + if !changed { + return nil + } + + 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 + } + if err := os.WriteFile(path, updated, 0644); err != nil { + return fmt.Errorf("failed to write dependabot.yml: %w", err) + } + 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 + } + + // 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 (goccy/go-yaml may return typed slices depending on YAML structure)", + value, + ) + + out := make([]any, rv.Len()) + for i := 0; i < rv.Len(); i++ { + out[i] = rv.Index(i).Interface() + } + 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 + } + + // 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 (goccy/go-yaml may return typed maps in dynamic sections)", + value, + ) + + 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 out, true +} + +func isYAMLNullOrEmptyScalar(value any) bool { + if value == nil { + return true + } + rawValue, ok := value.(string) + if !ok { + return false + } + trimmed := strings.TrimSpace(rawValue) + return trimmed == "" || strings.EqualFold(trimmed, "null") || trimmed == "~" +} + +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, managedDependabotIgnoreComment) { + continue + } + for _, pattern := range managedPatterns { + if strings.Contains(line, pattern) { + result[pattern] = true + } + } + } + return result +} + +func normalizeDependabotIgnoreEntries(content []byte, managedPatterns []string) []byte { + lines := strings.Split(string(content), "\n") + for i, line := range lines { + if !strings.Contains(line, "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: " + rawDependencyName := strings.TrimSpace(parts[1]) + quote := `"` + // 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 == "" { + continue + } + + line = prefix + quote + dependencyName + quote + + 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 + } + return []byte(strings.Join(lines, "\n")) +} + // 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 cee416e60f..6328b2e10d 100644 --- a/pkg/workflow/dependabot_test.go +++ b/pkg/workflow/dependabot_test.go @@ -344,6 +344,173 @@ 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 TestDependabotConfigPath(t *testing.T) { + 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) + } +} + +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-*") + 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 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-*")