From 4e6a7a03fcfa15e2a80fdbca0cfb07e5b9832d93 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 28 Jul 2026 12:43:16 -0500 Subject: [PATCH 1/8] cooldown: read from Dependabot config, drop the built-in default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI silently applied a hardcoded 3-day cooldown that users never configured and couldn't see. Cooldown now comes solely from the repo's `.github/dependabot.yml` (`github-actions` → `cooldown` → `default-days`), matching Dependabot's own ReleaseCooldownOptions, which defaults every day value to zero. An absent, github-actions-less, cooldown-less, or malformed config resolves to no cooldown rather than an error, so locking is never blocked. Because dropping the default removes the only guard against pinning a brand-new tag, a non-blocking freshness nudge takes its place: when no cooldown is configured, a tag pinned within 3 days of release is surfaced as an info finding (both upgrade suggestions and initial-lock pins), pointing the user at Dependabot's cooldown block. Uses release dates already fetched during resolution; tags without a GitHub Release have no published_at and aren't flagged, the same limitation the cooldown filter always had. Scope decisions: - Honor `default-days` only. If semver-*-days or include/exclude are set, emit a warning that they aren't honored rather than silently ignoring them. - Delete the invented `~/.config/gh-actions-lock/config.yml` cooldown format outright; nothing else read the surrounding Config struct. Came out of Andrew Nesbitt's external review of the lockfile CLI. --- cmd/gh-actions-lock/freshtag_test.go | 54 ++++++++ cmd/gh-actions-lock/run.go | 99 ++++++++++++++- internal/config/config.go | 142 ++++++++++------------ internal/config/config_test.go | 128 +++++++++++++++++++ internal/pipeline/checks/category.go | 11 ++ internal/pipeline/checks/category_test.go | 3 + internal/tag/cooldown.go | 37 ++---- internal/tag/cooldown_test.go | 23 ---- internal/tag/pure_test.go | 8 -- internal/tag/tags.go | 2 +- internal/tag/testing.go | 2 +- 11 files changed, 365 insertions(+), 144 deletions(-) create mode 100644 cmd/gh-actions-lock/freshtag_test.go create mode 100644 internal/config/config_test.go delete mode 100644 internal/tag/cooldown_test.go diff --git a/cmd/gh-actions-lock/freshtag_test.go b/cmd/gh-actions-lock/freshtag_test.go new file mode 100644 index 00000000..e650b861 --- /dev/null +++ b/cmd/gh-actions-lock/freshtag_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "testing" + "time" + + "github.com/github/gh-actions-lock/internal/pipeline/checks" +) + +func TestFreshTagFinding(t *testing.T) { + now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + iso := func(d time.Duration) string { return now.Add(-d).Format(time.RFC3339) } + + tests := []struct { + name string + iso string + wantOK bool + }{ + {"fresh", iso(24 * time.Hour), true}, + {"just under window", iso(freshTagWindow - time.Hour), true}, + {"exactly at window", iso(freshTagWindow), false}, + {"old", iso(30 * 24 * time.Hour), false}, + {"empty", "", false}, + {"unparseable", "not-a-date", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, ok := freshTagFinding("actions/checkout", "v4.2.1", tt.iso, now) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v", ok, tt.wantOK) + } + if ok { + if f.Category != checks.FreshTag || f.Severity != checks.SeverityInfo { + t.Errorf("finding category/severity = %s/%s", f.Category, f.Severity) + } + } + }) + } +} + +func TestAppendCooldownConfigFindings(t *testing.T) { + report := &checks.Report{} + appendCooldownConfigFindings(report, []string{"a", "b"}) + if len(report.RepoFindings) != 2 { + t.Fatalf("RepoFindings = %d, want 2", len(report.RepoFindings)) + } + if report.RepoFindings[0].Category != checks.CooldownConfigIgnored { + t.Errorf("category = %s, want cooldown-config-ignored", report.RepoFindings[0].Category) + } + appendCooldownConfigFindings(report, nil) + if len(report.RepoFindings) != 2 { + t.Errorf("nil warnings must add nothing; got %d", len(report.RepoFindings)) + } +} diff --git a/cmd/gh-actions-lock/run.go b/cmd/gh-actions-lock/run.go index ecf2d068..bd305861 100644 --- a/cmd/gh-actions-lock/run.go +++ b/cmd/gh-actions-lock/run.go @@ -10,7 +10,9 @@ import ( "path/filepath" "runtime/debug" "sort" + "strings" "sync" + "time" "github.com/cli/go-gh/v2/pkg/repository" parserlock "github.com/github/actions-lockfile/go/pkg/lockfile" @@ -245,10 +247,14 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) } // Build a Lister for pin narrowing, - // reusing the resolver's unified API client. + // reusing the resolver's unified API client. Cooldown is read from the + // repo's Dependabot config (or is absent — there is no built-in default). var tagger *tag.Lister + var cooldownCfg tag.CooldownConfig + var cooldownWarnings []string if gc := r.GHClient(); gc != nil { - tagger = tag.NewLister(gc, config.Load().Cooldown) + cooldownCfg, cooldownWarnings = config.LoadCooldown(".") + tagger = tag.NewLister(gc, cooldownCfg) } runOpts := pipeline.RunOptions{ @@ -414,6 +420,13 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) injectVersionRefFindings(report, record) } + // Surface Dependabot cooldown keys we don't honor, and nudge on freshly + // pinned tags when no cooldown is configured. Both are non-blocking. + appendCooldownConfigFindings(report, cooldownWarnings) + if tagger != nil && cooldownCfg.DefaultDays <= 0 { + injectFreshTagFindings(ctx, report, record, tagger) + } + // Write the run log. record.Repo = &pin.RepoInfo{Owner: repoOwner, Name: repoName, Host: resolveHostname(opts.hostname)} if path, werr := record.WriteJSON(); werr == nil && opts.jsonFields == "" { @@ -540,9 +553,89 @@ func appendStaleWorkflowFindings(report *checks.Report, workflows []string, prun } } +// freshTagWindow is the age below which a newly pinned tag is flagged when no +// Dependabot cooldown is configured. Matches the CLI's historical default so +// the nudge fires exactly where the old silent 3-day cooldown used to filter. +const freshTagWindow = 3 * 24 * time.Hour + +// appendCooldownConfigFindings surfaces Dependabot cooldown keys the tool does +// not honor as non-blocking repo-level findings, so ignored config is never +// silent. +func appendCooldownConfigFindings(report *checks.Report, warnings []string) { + for _, msg := range warnings { + report.RepoFindings = append(report.RepoFindings, checks.Finding{ + Category: checks.CooldownConfigIgnored, + Severity: checks.SeverityWarning, + Confidence: checks.ConfidenceHigh, + Detail: msg, + }) + } +} + +// injectFreshTagFindings warns about newly pinned tags released within +// freshTagWindow when no Dependabot cooldown is configured. It only inspects +// entries pinned this run (Resolution == Pinned) and relies on release dates +// already fetched during resolution, falling back to a single cached tag +// listing per repo. Tags without a GitHub Release have no published_at and are +// not flagged — the same limitation the cooldown filter has always had. +func injectFreshTagFindings(ctx context.Context, report *checks.Report, record *pin.Record, tagger *tag.Lister) { + seen := map[string]bool{} // NWO@tag → already flagged + for _, e := range record.Pinned() { + owner, repo, ok := strings.Cut(e.NWO, "/") + if !ok { + continue + } + tagName := e.Tag + if tagName == "" { + tagName = e.Ref + } + if _, isSemver := parserlock.ParseSemVer(tagName); !isSemver { + continue // branch or bare SHA — not a release tag + } + key := e.NWO + "@" + tagName + if seen[key] { + continue + } + + iso := tagger.ReleaseDate(owner, repo, tagName) + if iso == "" { + // Populate the release-date cache once (cached + singleflighted). + _, _ = tagger.ListTags(ctx, owner, repo) + iso = tagger.ReleaseDate(owner, repo, tagName) + } + finding, ok := freshTagFinding(e.NWO, tagName, iso, time.Now()) + if !ok { + continue + } + seen[key] = true + report.RepoFindings = append(report.RepoFindings, finding) + } +} + +// freshTagFinding builds a fresh-tag finding when iso parses to a release +// within freshTagWindow of now. It reports ok=false for an empty, unparseable, +// or old date so the caller skips it. +func freshTagFinding(nwo, tagName, iso string, now time.Time) (checks.Finding, bool) { + if iso == "" { + return checks.Finding{}, false + } + released, err := time.Parse(time.RFC3339, iso) + if err != nil || now.Sub(released) >= freshTagWindow { + return checks.Finding{}, false + } + return checks.Finding{ + Category: checks.FreshTag, + Severity: checks.SeverityInfo, + Confidence: checks.ConfidenceHigh, + Detail: fmt.Sprintf( + "%s@%s was released %s — pinned to a very recent tag with no Dependabot cooldown configured; add a github-actions `cooldown` block if you'd rather let fresh releases settle", + nwo, tagName, tag.FormatTagAge(iso), + ), + }, true +} + // cliVersion returns the gh-actions-lock extension version embedded by the Go // build system. Returns "(devel)" for local `go build` and a real version -// like "v0.1.2" when installed via `gh extension install`. func cliVersion() string { if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" { return info.Main.Version diff --git a/internal/config/config.go b/internal/config/config.go index 5e549a0a..936f5c59 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,105 +1,89 @@ -// Package config loads CLI configuration from file and environment. +// Package config loads the release-cooldown policy from a repository's +// Dependabot configuration. There is no built-in default: an absent, +// github-actions-less, cooldown-less, or malformed config resolves to an empty +// policy (no cooldown), matching Dependabot's own ReleaseCooldownOptions, which +// initializes every day value to zero. Locking is never blocked by a config +// problem. package config import ( + "fmt" "os" "path/filepath" - "strconv" "github.com/github/gh-actions-lock/internal/tag" "gopkg.in/yaml.v3" ) -// Config holds process-wide settings loaded from the config file and -// environment variables. Create one via LoadConfig at startup and pass -// it through the pipeline. -type Config struct { - // Path is the resolved config file path (empty if none found). - Path string - // Cooldown controls how recently a tag must have been released to - // be excluded from upgrade suggestions. - Cooldown tag.CooldownConfig - // Workers is the concurrency limit for pool-parallelized phases. - // Defaults to 8; overridden by GH_ACTIONS_LOCK_WORKERS. - Workers int - // StallHintMS is the stall-detection threshold in milliseconds. - // 0 disables the watcher. Overridden by GH_ACTIONS_LOCK_STALL_HINT_MS. - StallHintMS int - // DebugProgress enables per-phase progress tracing. - DebugProgress bool -} - -// Load reads the config file and environment, returning a Config -// with sensible defaults for any unset values. -func Load() Config { - p := configPath() - c := Config{ - Path: p, - Workers: 8, - StallHintMS: -1, // sentinel: use pinpool default - DebugProgress: envBool("GH_ACTIONS_LOCK_DEBUG_PROGRESS"), +// LoadCooldown reads the github-actions cooldown policy from repoRoot's +// Dependabot config (.github/dependabot.yml, falling back to .yaml). It returns +// the parsed policy plus human-readable warnings for any configured keys it +// does not yet honor, so the CLI never silently ignores configuration. +func LoadCooldown(repoRoot string) (tag.CooldownConfig, []string) { + data, ok := readDependabotFile(repoRoot) + if !ok { + return tag.CooldownConfig{}, nil } - - if v, err := strconv.Atoi(os.Getenv("GH_ACTIONS_LOCK_WORKERS")); err == nil && v > 0 { - c.Workers = v + var file dependabotConfig + if err := yaml.Unmarshal(data, &file); err != nil { + return tag.CooldownConfig{}, []string{ + fmt.Sprintf("ignoring malformed Dependabot config: %v", err), + } + } + cd, ok := file.actionsCooldown() + if !ok { + return tag.CooldownConfig{}, nil } - if v := os.Getenv("GH_ACTIONS_LOCK_STALL_HINT_MS"); v != "" { - if ms, err := strconv.Atoi(v); err == nil { - c.StallHintMS = ms + return tag.CooldownConfig{DefaultDays: cd.DefaultDays}, cd.unsupportedWarnings() +} + +func readDependabotFile(repoRoot string) ([]byte, bool) { + for _, name := range []string{"dependabot.yml", "dependabot.yaml"} { + if data, err := os.ReadFile(filepath.Join(repoRoot, ".github", name)); err == nil { + return data, true } } + return nil, false +} - c.Cooldown = loadCooldownFromFile(p) - return c +// dependabotConfig is the subset of Dependabot's schema this tool reads. +type dependabotConfig struct { + Updates []struct { + PackageEcosystem string `yaml:"package-ecosystem"` + Cooldown *cooldown `yaml:"cooldown"` + } `yaml:"updates"` } -func envBool(key string) bool { - v := os.Getenv(key) - return v == "1" || v == "true" || v == "yes" +// cooldown mirrors Dependabot's cooldown block. See +// dependabot-core common/lib/dependabot/package/release_cooldown_options.rb. +type cooldown struct { + DefaultDays int `yaml:"default-days"` + SemverMajorDays int `yaml:"semver-major-days"` + SemverMinorDays int `yaml:"semver-minor-days"` + SemverPatchDays int `yaml:"semver-patch-days"` + Include []string `yaml:"include"` + Exclude []string `yaml:"exclude"` } -// loadCooldownFromFile reads cooldown settings from the config file. -func loadCooldownFromFile(path string) tag.CooldownConfig { - cfg := tag.CooldownConfig{ - DefaultDays: 3, - RepoOverrides: make(map[string]int), - } - if path == "" { - return cfg - } - data, err := os.ReadFile(path) - if err != nil { - return cfg - } - var file struct { - CooldownDays int `yaml:"cooldown_days"` - Repos map[string]struct { - CooldownDays int `yaml:"cooldown_days"` - } `yaml:"repos"` - } - if err := yaml.Unmarshal(data, &file); err != nil { - return cfg - } - if file.CooldownDays > 0 { - cfg.DefaultDays = file.CooldownDays - } - for nwo, repoCfg := range file.Repos { - if repoCfg.CooldownDays >= 0 { - cfg.RepoOverrides[nwo] = repoCfg.CooldownDays +// actionsCooldown returns the cooldown block from the first github-actions +// update entry that has one. +func (c dependabotConfig) actionsCooldown() (cooldown, bool) { + for _, u := range c.Updates { + if u.PackageEcosystem == "github-actions" && u.Cooldown != nil { + return *u.Cooldown, true } } - return cfg + return cooldown{}, false } -// configPath returns the path to the config file, respecting -// GH_ACTIONS_LOCK_CONFIG for testing/demos. -func configPath() string { - if p := os.Getenv("GH_ACTIONS_LOCK_CONFIG"); p != "" { - return p +// unsupportedWarnings names cooldown keys that are set but not yet honored. +func (c cooldown) unsupportedWarnings() []string { + var w []string + if c.SemverMajorDays > 0 || c.SemverMinorDays > 0 || c.SemverPatchDays > 0 { + w = append(w, "Dependabot cooldown semver-major/minor/patch-days are not supported; applying default-days to all upgrades") } - home, err := os.UserHomeDir() - if err != nil { - return "" + if len(c.Include) > 0 || len(c.Exclude) > 0 { + w = append(w, "Dependabot cooldown include/exclude filters are not supported; applying default-days to all actions") } - return filepath.Join(home, ".config", "gh-actions-lock", "config.yml") + return w } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 00000000..a20fc8e1 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,128 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// writeDependabot writes body to /.github/ and returns dir. +func writeDependabot(t *testing.T, name, body string) string { + t.Helper() + dir := t.TempDir() + gh := filepath.Join(dir, ".github") + if err := os.MkdirAll(gh, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gh, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +func TestLoadCooldown_PresentWithCooldown(t *testing.T) { + dir := writeDependabot(t, "dependabot.yml", ` +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 5 +`) + cfg, warnings := LoadCooldown(dir) + if cfg.DefaultDays != 5 { + t.Errorf("DefaultDays = %d, want 5", cfg.DefaultDays) + } + if len(warnings) != 0 { + t.Errorf("warnings = %v, want none", warnings) + } +} + +func TestLoadCooldown_YAMLExtension(t *testing.T) { + dir := writeDependabot(t, "dependabot.yaml", ` +version: 2 +updates: + - package-ecosystem: "github-actions" + cooldown: + default-days: 2 +`) + cfg, _ := LoadCooldown(dir) + if cfg.DefaultDays != 2 { + t.Errorf("DefaultDays = %d, want 2 (.yaml must be read)", cfg.DefaultDays) + } +} + +func TestLoadCooldown_PresentWithoutCooldownBlock(t *testing.T) { + dir := writeDependabot(t, "dependabot.yml", ` +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" +`) + cfg, warnings := LoadCooldown(dir) + if cfg.DefaultDays != 0 { + t.Errorf("DefaultDays = %d, want 0 (no cooldown block)", cfg.DefaultDays) + } + if len(warnings) != 0 { + t.Errorf("warnings = %v, want none", warnings) + } +} + +func TestLoadCooldown_OtherEcosystemIgnored(t *testing.T) { + dir := writeDependabot(t, "dependabot.yml", ` +version: 2 +updates: + - package-ecosystem: "npm" + cooldown: + default-days: 9 +`) + cfg, _ := LoadCooldown(dir) + if cfg.DefaultDays != 0 { + t.Errorf("DefaultDays = %d, want 0 (only github-actions counts)", cfg.DefaultDays) + } +} + +func TestLoadCooldown_Absent(t *testing.T) { + cfg, warnings := LoadCooldown(t.TempDir()) + if cfg.DefaultDays != 0 { + t.Errorf("DefaultDays = %d, want 0 (no config file)", cfg.DefaultDays) + } + if len(warnings) != 0 { + t.Errorf("warnings = %v, want none", warnings) + } +} + +func TestLoadCooldown_Malformed(t *testing.T) { + dir := writeDependabot(t, "dependabot.yml", "updates: [this: is: not: valid") + cfg, warnings := LoadCooldown(dir) + if cfg.DefaultDays != 0 { + t.Errorf("DefaultDays = %d, want 0 (malformed must not block)", cfg.DefaultDays) + } + if len(warnings) != 1 { + t.Fatalf("warnings = %v, want one malformed-config warning", warnings) + } +} + +func TestLoadCooldown_UnsupportedKeysWarn(t *testing.T) { + dir := writeDependabot(t, "dependabot.yml", ` +version: 2 +updates: + - package-ecosystem: "github-actions" + cooldown: + default-days: 3 + semver-major-days: 30 + include: + - "actions/*" +`) + cfg, warnings := LoadCooldown(dir) + if cfg.DefaultDays != 3 { + t.Errorf("DefaultDays = %d, want 3", cfg.DefaultDays) + } + if len(warnings) != 2 { + t.Fatalf("warnings = %v, want 2 (semver-* and include/exclude)", warnings) + } +} diff --git a/internal/pipeline/checks/category.go b/internal/pipeline/checks/category.go index 458de9a2..802efcdc 100644 --- a/internal/pipeline/checks/category.go +++ b/internal/pipeline/checks/category.go @@ -80,6 +80,17 @@ const ( // InvalidSelfRepositoryRef means a `$/…` reference is malformed or its // target cannot be inspected. This includes a forbidden `@ref` suffix. InvalidSelfRepositoryRef Category = "invalid-self-repository-ref" + // FreshTag is an informational, non-blocking security nudge: an action was + // pinned to a tag released less than the freshness window (3 days) ago and + // no Dependabot cooldown is configured. Surfaced so operators know they + // locked a very recent release and can configure a cooldown if they'd + // rather let fresh releases settle. + FreshTag Category = "fresh-tag" + // CooldownConfigIgnored is an informational, non-blocking notice that the + // repository's Dependabot cooldown block sets keys this tool does not yet + // honor (e.g. semver-*-days or include/exclude). Surfaced so the ignored + // configuration is never silent. + CooldownConfigIgnored Category = "cooldown-config-ignored" ) // IsInconclusive reports whether c represents a diagnostic that diff --git a/internal/pipeline/checks/category_test.go b/internal/pipeline/checks/category_test.go index 43fb787a..f1ddc200 100644 --- a/internal/pipeline/checks/category_test.go +++ b/internal/pipeline/checks/category_test.go @@ -28,6 +28,8 @@ func TestCategoryStringsAreFrozen(t *testing.T) { {StaleWorkflow, "stale-workflow"}, {SelfRepositoryAction, "self-repository-action"}, {InvalidSelfRepositoryRef, "invalid-self-repository-ref"}, + {FreshTag, "fresh-tag"}, + {CooldownConfigIgnored, "cooldown-config-ignored"}, } for _, c := range cases { if string(c.got) != c.want { @@ -52,6 +54,7 @@ func TestCategoryIsInconclusive(t *testing.T) { Valid, RunOnly, OnboardingRequired, VersionRef, LocalAction, StaleWorkflow, SelfRepositoryAction, InvalidSelfRepositoryRef, + FreshTag, CooldownConfigIgnored, } for _, c := range blocking { if c.IsInconclusive() { diff --git a/internal/tag/cooldown.go b/internal/tag/cooldown.go index eae7bdad..97ad0275 100644 --- a/internal/tag/cooldown.go +++ b/internal/tag/cooldown.go @@ -2,34 +2,13 @@ // cooldown. package tag -import ( - "strings" - "time" -) - -// CooldownConfig controls the minimum age threshold for tag upgrade suggestions. +// CooldownConfig controls the minimum age a tag must have before it's +// suggested or pinned. It mirrors the `github-actions` cooldown block in a +// repository's Dependabot config. There is no built-in default: an absent +// config means DefaultDays is 0 and no cooldown filtering applies, matching +// Dependabot's own ReleaseCooldownOptions (every day value initializes to 0). type CooldownConfig struct { - DefaultDays int - RepoOverrides map[string]int -} - -// CooldownDays returns the cooldown period for a repo, falling back to the default. -func (c CooldownConfig) CooldownDays(owner, repo string) int { - if days, ok := c.RepoOverrides[owner+"/"+repo]; ok { - return days - } - // Override keys may be written with different owner/repo casing than the - // canonicalized lookup; fall back to a case-insensitive match. - want := strings.ToLower(owner + "/" + repo) - for k, days := range c.RepoOverrides { - if strings.ToLower(k) == want { - return days - } - } - return c.DefaultDays -} - -// CooldownDuration returns the cooldown as a time.Duration. -func (c CooldownConfig) CooldownDuration(owner, repo string) time.Duration { - return time.Duration(c.CooldownDays(owner, repo)) * 24 * time.Hour + // DefaultDays is Dependabot's cooldown `default-days`. Zero disables + // cooldown filtering. + DefaultDays int } diff --git a/internal/tag/cooldown_test.go b/internal/tag/cooldown_test.go deleted file mode 100644 index d58f3c9b..00000000 --- a/internal/tag/cooldown_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package tag - -import "testing" - -func TestCooldownDays_CaseInsensitiveOverride(t *testing.T) { - cfg := CooldownConfig{ - DefaultDays: 7, - RepoOverrides: map[string]int{"Actions/Checkout": 30}, - } - - // Exact-case match. - if got := cfg.CooldownDays("Actions", "Checkout"); got != 30 { - t.Errorf("exact case: want 30, got %d", got) - } - // Canonicalized (lowercased) lookup must still hit the override. - if got := cfg.CooldownDays("actions", "checkout"); got != 30 { - t.Errorf("lowercased: want 30, got %d", got) - } - // Unknown repo falls back to the default. - if got := cfg.CooldownDays("other", "repo"); got != 7 { - t.Errorf("default: want 7, got %d", got) - } -} diff --git a/internal/tag/pure_test.go b/internal/tag/pure_test.go index d8c49b35..32379fb0 100644 --- a/internal/tag/pure_test.go +++ b/internal/tag/pure_test.go @@ -8,14 +8,6 @@ import ( "github.com/stretchr/testify/assert" ) -func TestCooldownDuration(t *testing.T) { - cfg := CooldownConfig{DefaultDays: 7} - assert.Equal(t, 7*24*time.Hour, cfg.CooldownDuration("owner", "repo")) - - cfg = CooldownConfig{DefaultDays: 7, RepoOverrides: map[string]int{"a/b": 2}} - assert.Equal(t, 2*24*time.Hour, cfg.CooldownDuration("a", "b")) -} - func TestRepoInfoIsInternal(t *testing.T) { assert.True(t, RepoInfo{Visibility: "private"}.IsInternal()) assert.True(t, RepoInfo{Visibility: "internal"}.IsInternal()) diff --git a/internal/tag/tags.go b/internal/tag/tags.go index de70dc22..166b035b 100644 --- a/internal/tag/tags.go +++ b/internal/tag/tags.go @@ -236,7 +236,7 @@ func (tl *Lister) ReleaseDate(owner, repo, tag string) string { // isTagTooNew returns true if the tag's release date is younger than the cooldown period. // Tags without a known release date are never filtered (we can't determine their age). func (tl *Lister) isTagTooNew(owner, repo, tag string) bool { - days := tl.cooldown.CooldownDays(owner, repo) + days := tl.cooldown.DefaultDays if days <= 0 { return false } diff --git a/internal/tag/testing.go b/internal/tag/testing.go index 56f67ef5..2285f3c5 100644 --- a/internal/tag/testing.go +++ b/internal/tag/testing.go @@ -14,5 +14,5 @@ func NewListerForTest(t *testing.T, reg *httpmock.Registry) *Lister { if err != nil { t.Fatalf("ghapi.New: %v", err) } - return NewLister(client, CooldownConfig{DefaultDays: 3, RepoOverrides: map[string]int{}}) + return NewLister(client, CooldownConfig{DefaultDays: 3}) } From 14e757926132fa0a22e835ceefa529a00a7e7606 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 28 Jul 2026 13:08:15 -0500 Subject: [PATCH 2/8] config: strengthen Dependabot cooldown parsing tests Add a real .github/dependabot.yml fixture read off disk (testdata/repo) with multiple ecosystems and github-actions not first, plus edge cases: explicit default-days: 0, an empty cooldown block, two github-actions entries where the first has no cooldown, and per-key tag pinning so a mistyped struct tag can't hide behind a sibling key. --- internal/config/config_test.go | 92 +++++++++++++++++++ .../testdata/repo/.github/dependabot.yml | 25 +++++ 2 files changed, 117 insertions(+) create mode 100644 internal/config/testdata/repo/.github/dependabot.yml diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a20fc8e1..20bab498 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -20,6 +20,19 @@ func writeDependabot(t *testing.T, name, body string) string { return dir } +func TestLoadCooldown_RealWorldFixture(t *testing.T) { + // Reads an actual .github/dependabot.yml off disk (testdata/repo) whose + // shape mirrors GitHub's documented cooldown schema: multiple ecosystems, + // github-actions NOT first, and every documented cooldown key set. + cfg, warnings := LoadCooldown("testdata/repo") + if cfg.DefaultDays != 5 { + t.Errorf("DefaultDays = %d, want 5 (github-actions entry, not the npm 7)", cfg.DefaultDays) + } + if len(warnings) != 2 { + t.Fatalf("warnings = %v, want 2 (semver-* and include/exclude)", warnings) + } +} + func TestLoadCooldown_PresentWithCooldown(t *testing.T) { dir := writeDependabot(t, "dependabot.yml", ` version: 2 @@ -126,3 +139,82 @@ updates: t.Fatalf("warnings = %v, want 2 (semver-* and include/exclude)", warnings) } } + +func TestLoadCooldown_ExplicitZeroDays(t *testing.T) { + dir := writeDependabot(t, "dependabot.yml", ` +version: 2 +updates: + - package-ecosystem: "github-actions" + cooldown: + default-days: 0 +`) + cfg, warnings := LoadCooldown(dir) + if cfg.DefaultDays != 0 { + t.Errorf("DefaultDays = %d, want 0", cfg.DefaultDays) + } + if len(warnings) != 0 { + t.Errorf("explicit 0 is not an unsupported key; warnings = %v", warnings) + } +} + +func TestLoadCooldown_EmptyCooldownBlock(t *testing.T) { + // `cooldown:` with no mapping parses to a nil pointer, i.e. no block. + dir := writeDependabot(t, "dependabot.yml", ` +version: 2 +updates: + - package-ecosystem: "github-actions" + cooldown: +`) + cfg, warnings := LoadCooldown(dir) + if cfg.DefaultDays != 0 || len(warnings) != 0 { + t.Errorf("empty cooldown: got DefaultDays=%d warnings=%v, want 0 / none", cfg.DefaultDays, warnings) + } +} + +func TestLoadCooldown_FirstActionsEntryWithCooldownWins(t *testing.T) { + // Multi-directory setups can list github-actions twice; take the first + // entry that actually carries a cooldown block. + dir := writeDependabot(t, "dependabot.yml", ` +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + - package-ecosystem: "github-actions" + directory: "/nested" + cooldown: + default-days: 4 +`) + cfg, _ := LoadCooldown(dir) + if cfg.DefaultDays != 4 { + t.Errorf("DefaultDays = %d, want 4 (first entry with a cooldown block)", cfg.DefaultDays) + } +} + +// TestLoadCooldown_KeyTagsAreDistinct pins each YAML tag independently so a +// single mistyped struct tag can't hide behind a sibling key. semver-minor-days +// alone must trip the semver warning; exclude alone must trip the filter +// warning. +func TestLoadCooldown_KeyTagsAreDistinct(t *testing.T) { + semverOnly := writeDependabot(t, "dependabot.yml", ` +version: 2 +updates: + - package-ecosystem: "github-actions" + cooldown: + semver-minor-days: 7 +`) + if _, w := LoadCooldown(semverOnly); len(w) != 1 { + t.Errorf("semver-minor-days alone: warnings = %v, want 1", w) + } + + excludeOnly := writeDependabot(t, "dependabot.yml", ` +version: 2 +updates: + - package-ecosystem: "github-actions" + cooldown: + exclude: + - "actions/checkout" +`) + if _, w := LoadCooldown(excludeOnly); len(w) != 1 { + t.Errorf("exclude alone: warnings = %v, want 1", w) + } +} diff --git a/internal/config/testdata/repo/.github/dependabot.yml b/internal/config/testdata/repo/.github/dependabot.yml new file mode 100644 index 00000000..6934ce9d --- /dev/null +++ b/internal/config/testdata/repo/.github/dependabot.yml @@ -0,0 +1,25 @@ +# Real-world shape: multiple ecosystems, github-actions NOT first, and a full +# cooldown block using every documented key. Mirrors the schema at +# https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference#cooldown +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 5 + semver-major-days: 30 + semver-minor-days: 7 + semver-patch-days: 3 + include: + - "actions/*" + exclude: + - "actions/checkout" From 926a907f8c249403772d09fd4a430e6ecce27bc0 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 28 Jul 2026 21:37:51 -0500 Subject: [PATCH 3/8] cooldown: resolve dependabot > config file > fresh-tag warn Compose cooldown precedence explicitly: the repo's Dependabot github-actions cooldown is authoritative; else the user's ~/.config/gh-actions-lock config.yml (cooldown_days + per-repo overrides); else no filter, only the non-blocking <3-day fresh-tag warning. Split the loader into DependabotCooldown and FileCooldown, restore the config-file source and per-repo CooldownDays, and move the precedence composition into ResolveCooldown alongside the fresh-tag findings. --- cmd/gh-actions-lock/fresh_tag.go | 111 ++++++++++++++++++ cmd/gh-actions-lock/resolve_cooldown_test.go | 81 +++++++++++++ cmd/gh-actions-lock/run.go | 95 +--------------- internal/config/config.go | 113 ++++++++++--------- internal/config/config_test.go | 52 ++++----- internal/config/cooldown.go | 43 +++++++ internal/config/file_test.go | 66 +++++++++++ internal/tag/cooldown.go | 38 +++++-- internal/tag/tags.go | 2 +- 9 files changed, 424 insertions(+), 177 deletions(-) create mode 100644 cmd/gh-actions-lock/fresh_tag.go create mode 100644 cmd/gh-actions-lock/resolve_cooldown_test.go create mode 100644 internal/config/cooldown.go create mode 100644 internal/config/file_test.go diff --git a/cmd/gh-actions-lock/fresh_tag.go b/cmd/gh-actions-lock/fresh_tag.go new file mode 100644 index 00000000..8b51cc38 --- /dev/null +++ b/cmd/gh-actions-lock/fresh_tag.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "fmt" + "strings" + "time" + + parserlock "github.com/github/actions-lockfile/go/pkg/lockfile" + "github.com/github/gh-actions-lock/internal/config" + "github.com/github/gh-actions-lock/internal/pin" + "github.com/github/gh-actions-lock/internal/pipeline/checks" + "github.com/github/gh-actions-lock/internal/tag" +) + +// ResolveCooldown picks the release-cooldown policy by precedence: the repo's +// Dependabot config is gospel; else the user's ~/.config/gh-actions-lock +// config file; else no policy. configured is false only in that last case, so +// the caller applies the global fresh-tag warn instead of a silent filter. +// warnings carries any Dependabot keys we don't honor (surfaced non-blocking). +func ResolveCooldown(repoRoot string) (cfg tag.CooldownConfig, configured bool, warnings []string) { + cfg, ok, warnings := config.DependabotCooldown(repoRoot) + if ok { + return cfg, true, warnings + } + if fileCfg, ok := config.FileCooldown(); ok { + return fileCfg, true, warnings + } + return tag.CooldownConfig{}, false, warnings +} + +// freshTagWindow is the age below which a newly pinned tag is flagged when no +// Dependabot cooldown is configured. Matches the CLI's historical default so +// the nudge fires exactly where the old silent 3-day cooldown used to filter. +const freshTagWindow = 3 * 24 * time.Hour + +// appendCooldownConfigFindings surfaces Dependabot cooldown keys the tool does +// not honor as non-blocking repo-level findings, so ignored config is never +// silent. +func appendCooldownConfigFindings(report *checks.Report, warnings []string) { + for _, msg := range warnings { + report.RepoFindings = append(report.RepoFindings, checks.Finding{ + Category: checks.CooldownConfigIgnored, + Severity: checks.SeverityWarning, + Confidence: checks.ConfidenceHigh, + Detail: msg, + }) + } +} + +// injectFreshTagFindings warns about newly pinned tags released within +// freshTagWindow when no Dependabot cooldown is configured. It only inspects +// entries pinned this run (Resolution == Pinned) and relies on release dates +// already fetched during resolution, falling back to a single cached tag +// listing per repo. Tags without a GitHub Release have no published_at and are +// not flagged — the same limitation the cooldown filter has always had. +func injectFreshTagFindings(ctx context.Context, report *checks.Report, record *pin.Record, tagger *tag.Lister) { + seen := map[string]bool{} // NWO@tag → already flagged + for _, e := range record.Pinned() { + owner, repo, ok := strings.Cut(e.NWO, "/") + if !ok { + continue + } + tagName := e.Tag + if tagName == "" { + tagName = e.Ref + } + if _, isSemver := parserlock.ParseSemVer(tagName); !isSemver { + continue // branch or bare SHA — not a release tag + } + key := e.NWO + "@" + tagName + if seen[key] { + continue + } + + iso := tagger.ReleaseDate(owner, repo, tagName) + if iso == "" { + // Populate the release-date cache once (cached + singleflighted). + _, _ = tagger.ListTags(ctx, owner, repo) + iso = tagger.ReleaseDate(owner, repo, tagName) + } + finding, ok := freshTagFinding(e.NWO, tagName, iso, time.Now()) + if !ok { + continue + } + seen[key] = true + report.RepoFindings = append(report.RepoFindings, finding) + } +} + +// freshTagFinding builds a fresh-tag finding when iso parses to a release +// within freshTagWindow of now. It reports ok=false for an empty, unparseable, +// or old date so the caller skips it. +func freshTagFinding(nwo, tagName, iso string, now time.Time) (checks.Finding, bool) { + if iso == "" { + return checks.Finding{}, false + } + released, err := time.Parse(time.RFC3339, iso) + if err != nil || now.Sub(released) >= freshTagWindow { + return checks.Finding{}, false + } + return checks.Finding{ + Category: checks.FreshTag, + Severity: checks.SeverityInfo, + Confidence: checks.ConfidenceHigh, + Detail: fmt.Sprintf( + "%s@%s was released %s — pinned to a very recent tag with no Dependabot cooldown configured; add a github-actions `cooldown` block if you'd rather let fresh releases settle", + nwo, tagName, tag.FormatTagAge(iso), + ), + }, true +} diff --git a/cmd/gh-actions-lock/resolve_cooldown_test.go b/cmd/gh-actions-lock/resolve_cooldown_test.go new file mode 100644 index 00000000..9864b5c7 --- /dev/null +++ b/cmd/gh-actions-lock/resolve_cooldown_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// writeRepoDependabot writes a .github/dependabot.yml under a fresh temp dir +// and returns the dir (a stand-in repo root). +func writeRepoDependabot(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + gh := filepath.Join(dir, ".github") + if err := os.MkdirAll(gh, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gh, "dependabot.yml"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +func TestResolveCooldown_DependabotWins(t *testing.T) { + // Both sources present: Dependabot is gospel, the config file is ignored. + writeUserFileConfig(t, "cooldown_days: 9\n") + dir := writeRepoDependabot(t, ` +version: 2 +updates: + - package-ecosystem: "github-actions" + cooldown: + default-days: 5 +`) + cfg, configured, _ := ResolveCooldown(dir) + if !configured || cfg.DefaultDays != 5 { + t.Fatalf("got cfg=%+v configured=%v, want DefaultDays 5 (Dependabot wins)", cfg, configured) + } +} + +func TestResolveCooldown_FileFallback(t *testing.T) { + // No Dependabot cooldown -> fall back to the user config file. + writeUserFileConfig(t, "cooldown_days: 9\n") + cfg, configured, _ := ResolveCooldown(t.TempDir()) + if !configured || cfg.DefaultDays != 9 { + t.Fatalf("got cfg=%+v configured=%v, want DefaultDays 9 (file fallback)", cfg, configured) + } +} + +func TestResolveCooldown_NeitherIsUnconfigured(t *testing.T) { + // No Dependabot, no config file -> unconfigured, so the caller applies the + // global fresh-tag warn rather than a silent filter. + t.Setenv("GH_ACTIONS_LOCK_CONFIG", filepath.Join(t.TempDir(), "none.yml")) + cfg, configured, _ := ResolveCooldown(t.TempDir()) + if configured || cfg.DefaultDays != 0 { + t.Fatalf("got cfg=%+v configured=%v, want empty / false", cfg, configured) + } +} + +func TestResolveCooldown_MalformedDependabotFallsBackAndWarns(t *testing.T) { + // Malformed Dependabot still surfaces a warning but falls through to the + // config file rather than blocking. + writeUserFileConfig(t, "cooldown_days: 6\n") + dir := writeRepoDependabot(t, "updates: [ this is : broken\n") + cfg, configured, warnings := ResolveCooldown(dir) + if !configured || cfg.DefaultDays != 6 { + t.Errorf("got cfg=%+v configured=%v, want DefaultDays 6", cfg, configured) + } + if len(warnings) != 1 { + t.Errorf("warnings = %v, want 1 (malformed dependabot)", warnings) + } +} + +// writeUserFileConfig points GH_ACTIONS_LOCK_CONFIG at a temp config.yml. +func writeUserFileConfig(t *testing.T, body string) { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yml") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("GH_ACTIONS_LOCK_CONFIG", path) +} diff --git a/cmd/gh-actions-lock/run.go b/cmd/gh-actions-lock/run.go index bd305861..587c5437 100644 --- a/cmd/gh-actions-lock/run.go +++ b/cmd/gh-actions-lock/run.go @@ -10,14 +10,11 @@ import ( "path/filepath" "runtime/debug" "sort" - "strings" "sync" - "time" "github.com/cli/go-gh/v2/pkg/repository" parserlock "github.com/github/actions-lockfile/go/pkg/lockfile" "github.com/github/gh-actions-lock/cmd/gh-actions-lock/format" - "github.com/github/gh-actions-lock/internal/config" "github.com/github/gh-actions-lock/internal/pin" "github.com/github/gh-actions-lock/internal/pinpool" "github.com/github/gh-actions-lock/internal/pipeline" @@ -246,14 +243,13 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) console.StartProgress(fmt.Sprintf("Scanning %d %s", total, ui.Pluralize(total, "workflow", "workflows"))) } - // Build a Lister for pin narrowing, - // reusing the resolver's unified API client. Cooldown is read from the - // repo's Dependabot config (or is absent — there is no built-in default). + // Build a Lister for pin narrowing, reusing the resolver's API client; cooldown is resolved dependabot > config file > none. var tagger *tag.Lister var cooldownCfg tag.CooldownConfig + var cooldownConfigured bool var cooldownWarnings []string if gc := r.GHClient(); gc != nil { - cooldownCfg, cooldownWarnings = config.LoadCooldown(".") + cooldownCfg, cooldownConfigured, cooldownWarnings = ResolveCooldown(".") tagger = tag.NewLister(gc, cooldownCfg) } @@ -421,9 +417,9 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) } // Surface Dependabot cooldown keys we don't honor, and nudge on freshly - // pinned tags when no cooldown is configured. Both are non-blocking. + // pinned tags when no cooldown policy is configured. Both are non-blocking. appendCooldownConfigFindings(report, cooldownWarnings) - if tagger != nil && cooldownCfg.DefaultDays <= 0 { + if tagger != nil && !cooldownConfigured { injectFreshTagFindings(ctx, report, record, tagger) } @@ -553,87 +549,6 @@ func appendStaleWorkflowFindings(report *checks.Report, workflows []string, prun } } -// freshTagWindow is the age below which a newly pinned tag is flagged when no -// Dependabot cooldown is configured. Matches the CLI's historical default so -// the nudge fires exactly where the old silent 3-day cooldown used to filter. -const freshTagWindow = 3 * 24 * time.Hour - -// appendCooldownConfigFindings surfaces Dependabot cooldown keys the tool does -// not honor as non-blocking repo-level findings, so ignored config is never -// silent. -func appendCooldownConfigFindings(report *checks.Report, warnings []string) { - for _, msg := range warnings { - report.RepoFindings = append(report.RepoFindings, checks.Finding{ - Category: checks.CooldownConfigIgnored, - Severity: checks.SeverityWarning, - Confidence: checks.ConfidenceHigh, - Detail: msg, - }) - } -} - -// injectFreshTagFindings warns about newly pinned tags released within -// freshTagWindow when no Dependabot cooldown is configured. It only inspects -// entries pinned this run (Resolution == Pinned) and relies on release dates -// already fetched during resolution, falling back to a single cached tag -// listing per repo. Tags without a GitHub Release have no published_at and are -// not flagged — the same limitation the cooldown filter has always had. -func injectFreshTagFindings(ctx context.Context, report *checks.Report, record *pin.Record, tagger *tag.Lister) { - seen := map[string]bool{} // NWO@tag → already flagged - for _, e := range record.Pinned() { - owner, repo, ok := strings.Cut(e.NWO, "/") - if !ok { - continue - } - tagName := e.Tag - if tagName == "" { - tagName = e.Ref - } - if _, isSemver := parserlock.ParseSemVer(tagName); !isSemver { - continue // branch or bare SHA — not a release tag - } - key := e.NWO + "@" + tagName - if seen[key] { - continue - } - - iso := tagger.ReleaseDate(owner, repo, tagName) - if iso == "" { - // Populate the release-date cache once (cached + singleflighted). - _, _ = tagger.ListTags(ctx, owner, repo) - iso = tagger.ReleaseDate(owner, repo, tagName) - } - finding, ok := freshTagFinding(e.NWO, tagName, iso, time.Now()) - if !ok { - continue - } - seen[key] = true - report.RepoFindings = append(report.RepoFindings, finding) - } -} - -// freshTagFinding builds a fresh-tag finding when iso parses to a release -// within freshTagWindow of now. It reports ok=false for an empty, unparseable, -// or old date so the caller skips it. -func freshTagFinding(nwo, tagName, iso string, now time.Time) (checks.Finding, bool) { - if iso == "" { - return checks.Finding{}, false - } - released, err := time.Parse(time.RFC3339, iso) - if err != nil || now.Sub(released) >= freshTagWindow { - return checks.Finding{}, false - } - return checks.Finding{ - Category: checks.FreshTag, - Severity: checks.SeverityInfo, - Confidence: checks.ConfidenceHigh, - Detail: fmt.Sprintf( - "%s@%s was released %s — pinned to a very recent tag with no Dependabot cooldown configured; add a github-actions `cooldown` block if you'd rather let fresh releases settle", - nwo, tagName, tag.FormatTagAge(iso), - ), - }, true -} - // cliVersion returns the gh-actions-lock extension version embedded by the Go // build system. Returns "(devel)" for local `go build` and a real version func cliVersion() string { diff --git a/internal/config/config.go b/internal/config/config.go index 936f5c59..ba0b1953 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,9 +1,7 @@ -// Package config loads the release-cooldown policy from a repository's -// Dependabot configuration. There is no built-in default: an absent, -// github-actions-less, cooldown-less, or malformed config resolves to an empty -// policy (no cooldown), matching Dependabot's own ReleaseCooldownOptions, which -// initializes every day value to zero. Locking is never blocked by a config -// problem. +// Package config loads the release-cooldown policy for a repository from two +// sources: the repo's Dependabot config (authoritative) and the user's +// ~/.config/gh-actions-lock/config.yml (fallback). Precedence is composed by +// the command; see ResolveCooldown in cmd/gh-actions-lock/fresh_tag.go. package config import ( @@ -15,26 +13,27 @@ import ( "gopkg.in/yaml.v3" ) -// LoadCooldown reads the github-actions cooldown policy from repoRoot's -// Dependabot config (.github/dependabot.yml, falling back to .yaml). It returns -// the parsed policy plus human-readable warnings for any configured keys it -// does not yet honor, so the CLI never silently ignores configuration. -func LoadCooldown(repoRoot string) (tag.CooldownConfig, []string) { - data, ok := readDependabotFile(repoRoot) - if !ok { - return tag.CooldownConfig{}, nil +// DependabotCooldown reads the github-actions cooldown policy from repoRoot's +// Dependabot config (.github/dependabot.yml, falling back to .yaml). ok is true +// only when a github-actions entry carries a cooldown block. warnings names +// configured keys the tool does not yet honor, so config is never silently +// ignored. An absent or malformed config yields ok=false and never blocks. +func DependabotCooldown(repoRoot string) (cfg tag.CooldownConfig, ok bool, warnings []string) { + data, found := readDependabotFile(repoRoot) + if !found { + return tag.CooldownConfig{}, false, nil } var file dependabotConfig if err := yaml.Unmarshal(data, &file); err != nil { - return tag.CooldownConfig{}, []string{ + return tag.CooldownConfig{}, false, []string{ fmt.Sprintf("ignoring malformed Dependabot config: %v", err), } } - cd, ok := file.actionsCooldown() - if !ok { - return tag.CooldownConfig{}, nil + cd, has := file.actionsCooldown() + if !has { + return tag.CooldownConfig{}, false, nil } - return tag.CooldownConfig{DefaultDays: cd.DefaultDays}, cd.unsupportedWarnings() + return tag.CooldownConfig{DefaultDays: cd.DefaultDays}, true, cd.unsupportedWarnings() } func readDependabotFile(repoRoot string) ([]byte, bool) { @@ -46,44 +45,52 @@ func readDependabotFile(repoRoot string) ([]byte, bool) { return nil, false } -// dependabotConfig is the subset of Dependabot's schema this tool reads. -type dependabotConfig struct { - Updates []struct { - PackageEcosystem string `yaml:"package-ecosystem"` - Cooldown *cooldown `yaml:"cooldown"` - } `yaml:"updates"` -} - -// cooldown mirrors Dependabot's cooldown block. See -// dependabot-core common/lib/dependabot/package/release_cooldown_options.rb. -type cooldown struct { - DefaultDays int `yaml:"default-days"` - SemverMajorDays int `yaml:"semver-major-days"` - SemverMinorDays int `yaml:"semver-minor-days"` - SemverPatchDays int `yaml:"semver-patch-days"` - Include []string `yaml:"include"` - Exclude []string `yaml:"exclude"` -} - -// actionsCooldown returns the cooldown block from the first github-actions -// update entry that has one. -func (c dependabotConfig) actionsCooldown() (cooldown, bool) { - for _, u := range c.Updates { - if u.PackageEcosystem == "github-actions" && u.Cooldown != nil { - return *u.Cooldown, true +// FileCooldown reads the user's ~/.config/gh-actions-lock/config.yml cooldown +// settings (top-level cooldown_days plus per-repo repos: overrides). ok is true +// only when the file supplies at least one of those. A missing or malformed +// file yields ok=false and never blocks. +func FileCooldown() (cfg tag.CooldownConfig, ok bool) { + path := configPath() + if path == "" { + return tag.CooldownConfig{}, false + } + data, err := os.ReadFile(path) + if err != nil { + return tag.CooldownConfig{}, false + } + var file struct { + CooldownDays int `yaml:"cooldown_days"` + Repos map[string]struct { + CooldownDays int `yaml:"cooldown_days"` + } `yaml:"repos"` + } + if err := yaml.Unmarshal(data, &file); err != nil { + return tag.CooldownConfig{}, false + } + if file.CooldownDays <= 0 && len(file.Repos) == 0 { + return tag.CooldownConfig{}, false + } + cfg = tag.CooldownConfig{DefaultDays: file.CooldownDays} + if len(file.Repos) > 0 { + cfg.RepoOverrides = make(map[string]int, len(file.Repos)) + for nwo, rc := range file.Repos { + if rc.CooldownDays >= 0 { + cfg.RepoOverrides[nwo] = rc.CooldownDays + } } } - return cooldown{}, false + return cfg, true } -// unsupportedWarnings names cooldown keys that are set but not yet honored. -func (c cooldown) unsupportedWarnings() []string { - var w []string - if c.SemverMajorDays > 0 || c.SemverMinorDays > 0 || c.SemverPatchDays > 0 { - w = append(w, "Dependabot cooldown semver-major/minor/patch-days are not supported; applying default-days to all upgrades") +// configPath returns the config file path, respecting GH_ACTIONS_LOCK_CONFIG +// for testing and demos. +func configPath() string { + if p := os.Getenv("GH_ACTIONS_LOCK_CONFIG"); p != "" { + return p } - if len(c.Include) > 0 || len(c.Exclude) > 0 { - w = append(w, "Dependabot cooldown include/exclude filters are not supported; applying default-days to all actions") + home, err := os.UserHomeDir() + if err != nil { + return "" } - return w + return filepath.Join(home, ".config", "gh-actions-lock", "config.yml") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 20bab498..0c1d4045 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -20,11 +20,11 @@ func writeDependabot(t *testing.T, name, body string) string { return dir } -func TestLoadCooldown_RealWorldFixture(t *testing.T) { +func TestDependabotCooldown_RealWorldFixture(t *testing.T) { // Reads an actual .github/dependabot.yml off disk (testdata/repo) whose // shape mirrors GitHub's documented cooldown schema: multiple ecosystems, // github-actions NOT first, and every documented cooldown key set. - cfg, warnings := LoadCooldown("testdata/repo") + cfg, _, warnings := DependabotCooldown("testdata/repo") if cfg.DefaultDays != 5 { t.Errorf("DefaultDays = %d, want 5 (github-actions entry, not the npm 7)", cfg.DefaultDays) } @@ -33,7 +33,7 @@ func TestLoadCooldown_RealWorldFixture(t *testing.T) { } } -func TestLoadCooldown_PresentWithCooldown(t *testing.T) { +func TestDependabotCooldown_PresentWithCooldown(t *testing.T) { dir := writeDependabot(t, "dependabot.yml", ` version: 2 updates: @@ -44,7 +44,7 @@ updates: cooldown: default-days: 5 `) - cfg, warnings := LoadCooldown(dir) + cfg, _, warnings := DependabotCooldown(dir) if cfg.DefaultDays != 5 { t.Errorf("DefaultDays = %d, want 5", cfg.DefaultDays) } @@ -53,7 +53,7 @@ updates: } } -func TestLoadCooldown_YAMLExtension(t *testing.T) { +func TestDependabotCooldown_YAMLExtension(t *testing.T) { dir := writeDependabot(t, "dependabot.yaml", ` version: 2 updates: @@ -61,13 +61,13 @@ updates: cooldown: default-days: 2 `) - cfg, _ := LoadCooldown(dir) + cfg, _, _ := DependabotCooldown(dir) if cfg.DefaultDays != 2 { t.Errorf("DefaultDays = %d, want 2 (.yaml must be read)", cfg.DefaultDays) } } -func TestLoadCooldown_PresentWithoutCooldownBlock(t *testing.T) { +func TestDependabotCooldown_PresentWithoutCooldownBlock(t *testing.T) { dir := writeDependabot(t, "dependabot.yml", ` version: 2 updates: @@ -76,7 +76,7 @@ updates: schedule: interval: "weekly" `) - cfg, warnings := LoadCooldown(dir) + cfg, _, warnings := DependabotCooldown(dir) if cfg.DefaultDays != 0 { t.Errorf("DefaultDays = %d, want 0 (no cooldown block)", cfg.DefaultDays) } @@ -85,7 +85,7 @@ updates: } } -func TestLoadCooldown_OtherEcosystemIgnored(t *testing.T) { +func TestDependabotCooldown_OtherEcosystemIgnored(t *testing.T) { dir := writeDependabot(t, "dependabot.yml", ` version: 2 updates: @@ -93,14 +93,14 @@ updates: cooldown: default-days: 9 `) - cfg, _ := LoadCooldown(dir) + cfg, _, _ := DependabotCooldown(dir) if cfg.DefaultDays != 0 { t.Errorf("DefaultDays = %d, want 0 (only github-actions counts)", cfg.DefaultDays) } } -func TestLoadCooldown_Absent(t *testing.T) { - cfg, warnings := LoadCooldown(t.TempDir()) +func TestDependabotCooldown_Absent(t *testing.T) { + cfg, _, warnings := DependabotCooldown(t.TempDir()) if cfg.DefaultDays != 0 { t.Errorf("DefaultDays = %d, want 0 (no config file)", cfg.DefaultDays) } @@ -109,9 +109,9 @@ func TestLoadCooldown_Absent(t *testing.T) { } } -func TestLoadCooldown_Malformed(t *testing.T) { +func TestDependabotCooldown_Malformed(t *testing.T) { dir := writeDependabot(t, "dependabot.yml", "updates: [this: is: not: valid") - cfg, warnings := LoadCooldown(dir) + cfg, _, warnings := DependabotCooldown(dir) if cfg.DefaultDays != 0 { t.Errorf("DefaultDays = %d, want 0 (malformed must not block)", cfg.DefaultDays) } @@ -120,7 +120,7 @@ func TestLoadCooldown_Malformed(t *testing.T) { } } -func TestLoadCooldown_UnsupportedKeysWarn(t *testing.T) { +func TestDependabotCooldown_UnsupportedKeysWarn(t *testing.T) { dir := writeDependabot(t, "dependabot.yml", ` version: 2 updates: @@ -131,7 +131,7 @@ updates: include: - "actions/*" `) - cfg, warnings := LoadCooldown(dir) + cfg, _, warnings := DependabotCooldown(dir) if cfg.DefaultDays != 3 { t.Errorf("DefaultDays = %d, want 3", cfg.DefaultDays) } @@ -140,7 +140,7 @@ updates: } } -func TestLoadCooldown_ExplicitZeroDays(t *testing.T) { +func TestDependabotCooldown_ExplicitZeroDays(t *testing.T) { dir := writeDependabot(t, "dependabot.yml", ` version: 2 updates: @@ -148,7 +148,7 @@ updates: cooldown: default-days: 0 `) - cfg, warnings := LoadCooldown(dir) + cfg, _, warnings := DependabotCooldown(dir) if cfg.DefaultDays != 0 { t.Errorf("DefaultDays = %d, want 0", cfg.DefaultDays) } @@ -157,7 +157,7 @@ updates: } } -func TestLoadCooldown_EmptyCooldownBlock(t *testing.T) { +func TestDependabotCooldown_EmptyCooldownBlock(t *testing.T) { // `cooldown:` with no mapping parses to a nil pointer, i.e. no block. dir := writeDependabot(t, "dependabot.yml", ` version: 2 @@ -165,13 +165,13 @@ updates: - package-ecosystem: "github-actions" cooldown: `) - cfg, warnings := LoadCooldown(dir) + cfg, _, warnings := DependabotCooldown(dir) if cfg.DefaultDays != 0 || len(warnings) != 0 { t.Errorf("empty cooldown: got DefaultDays=%d warnings=%v, want 0 / none", cfg.DefaultDays, warnings) } } -func TestLoadCooldown_FirstActionsEntryWithCooldownWins(t *testing.T) { +func TestDependabotCooldown_FirstActionsEntryWithCooldownWins(t *testing.T) { // Multi-directory setups can list github-actions twice; take the first // entry that actually carries a cooldown block. dir := writeDependabot(t, "dependabot.yml", ` @@ -184,17 +184,17 @@ updates: cooldown: default-days: 4 `) - cfg, _ := LoadCooldown(dir) + cfg, _, _ := DependabotCooldown(dir) if cfg.DefaultDays != 4 { t.Errorf("DefaultDays = %d, want 4 (first entry with a cooldown block)", cfg.DefaultDays) } } -// TestLoadCooldown_KeyTagsAreDistinct pins each YAML tag independently so a +// TestDependabotCooldown_KeyTagsAreDistinct pins each YAML tag independently so a // single mistyped struct tag can't hide behind a sibling key. semver-minor-days // alone must trip the semver warning; exclude alone must trip the filter // warning. -func TestLoadCooldown_KeyTagsAreDistinct(t *testing.T) { +func TestDependabotCooldown_KeyTagsAreDistinct(t *testing.T) { semverOnly := writeDependabot(t, "dependabot.yml", ` version: 2 updates: @@ -202,7 +202,7 @@ updates: cooldown: semver-minor-days: 7 `) - if _, w := LoadCooldown(semverOnly); len(w) != 1 { + if _, _, w := DependabotCooldown(semverOnly); len(w) != 1 { t.Errorf("semver-minor-days alone: warnings = %v, want 1", w) } @@ -214,7 +214,7 @@ updates: exclude: - "actions/checkout" `) - if _, w := LoadCooldown(excludeOnly); len(w) != 1 { + if _, _, w := DependabotCooldown(excludeOnly); len(w) != 1 { t.Errorf("exclude alone: warnings = %v, want 1", w) } } diff --git a/internal/config/cooldown.go b/internal/config/cooldown.go new file mode 100644 index 00000000..7c38ba40 --- /dev/null +++ b/internal/config/cooldown.go @@ -0,0 +1,43 @@ +package config + +// dependabotConfig is the subset of Dependabot's schema this tool reads. +type dependabotConfig struct { + Updates []struct { + PackageEcosystem string `yaml:"package-ecosystem"` + Cooldown *cooldown `yaml:"cooldown"` + } `yaml:"updates"` +} + +// cooldown mirrors Dependabot's cooldown block. See +// dependabot-core common/lib/dependabot/package/release_cooldown_options.rb. +type cooldown struct { + DefaultDays int `yaml:"default-days"` + SemverMajorDays int `yaml:"semver-major-days"` + SemverMinorDays int `yaml:"semver-minor-days"` + SemverPatchDays int `yaml:"semver-patch-days"` + Include []string `yaml:"include"` + Exclude []string `yaml:"exclude"` +} + +// actionsCooldown returns the cooldown block from the first github-actions +// update entry that has one. +func (c dependabotConfig) actionsCooldown() (cooldown, bool) { + for _, u := range c.Updates { + if u.PackageEcosystem == "github-actions" && u.Cooldown != nil { + return *u.Cooldown, true + } + } + return cooldown{}, false +} + +// unsupportedWarnings names cooldown keys that are set but not yet honored. +func (c cooldown) unsupportedWarnings() []string { + var w []string + if c.SemverMajorDays > 0 || c.SemverMinorDays > 0 || c.SemverPatchDays > 0 { + w = append(w, "Dependabot cooldown semver-major/minor/patch-days are not supported; applying default-days to all upgrades") + } + if len(c.Include) > 0 || len(c.Exclude) > 0 { + w = append(w, "Dependabot cooldown include/exclude filters are not supported; applying default-days to all actions") + } + return w +} diff --git a/internal/config/file_test.go b/internal/config/file_test.go new file mode 100644 index 00000000..647d9894 --- /dev/null +++ b/internal/config/file_test.go @@ -0,0 +1,66 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// writeUserConfig writes body to a temp config.yml and points +// GH_ACTIONS_LOCK_CONFIG at it for the duration of the test. +func writeUserConfig(t *testing.T, body string) { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yml") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("GH_ACTIONS_LOCK_CONFIG", path) +} + +func TestFileCooldown_DefaultDays(t *testing.T) { + writeUserConfig(t, "cooldown_days: 4\n") + cfg, ok := FileCooldown() + if !ok || cfg.DefaultDays != 4 { + t.Fatalf("got cfg=%+v ok=%v, want DefaultDays 4 / true", cfg, ok) + } +} + +func TestFileCooldown_RepoOverrides(t *testing.T) { + writeUserConfig(t, ` +cooldown_days: 2 +repos: + actions/checkout: + cooldown_days: 10 +`) + cfg, ok := FileCooldown() + if !ok { + t.Fatal("ok = false, want true") + } + if got := cfg.CooldownDays("actions", "checkout"); got != 10 { + t.Errorf("override CooldownDays = %d, want 10", got) + } + if got := cfg.CooldownDays("actions", "setup-go"); got != 2 { + t.Errorf("fallback CooldownDays = %d, want 2 (default)", got) + } +} + +func TestFileCooldown_Absent(t *testing.T) { + t.Setenv("GH_ACTIONS_LOCK_CONFIG", filepath.Join(t.TempDir(), "does-not-exist.yml")) + if cfg, ok := FileCooldown(); ok { + t.Errorf("absent file: got cfg=%+v ok=true, want false", cfg) + } +} + +func TestFileCooldown_EmptyIsNotConfigured(t *testing.T) { + writeUserConfig(t, "cooldown_days: 0\n") + if cfg, ok := FileCooldown(); ok { + t.Errorf("cooldown_days 0 with no repos: got cfg=%+v ok=true, want false", cfg) + } +} + +func TestFileCooldown_Malformed(t *testing.T) { + writeUserConfig(t, "cooldown_days: [not, an, int\n") + if _, ok := FileCooldown(); ok { + t.Error("malformed file: ok = true, want false") + } +} diff --git a/internal/tag/cooldown.go b/internal/tag/cooldown.go index 97ad0275..82f8d3cc 100644 --- a/internal/tag/cooldown.go +++ b/internal/tag/cooldown.go @@ -2,13 +2,37 @@ // cooldown. package tag +import ( + "strings" + "time" +) + // CooldownConfig controls the minimum age a tag must have before it's -// suggested or pinned. It mirrors the `github-actions` cooldown block in a -// repository's Dependabot config. There is no built-in default: an absent -// config means DefaultDays is 0 and no cooldown filtering applies, matching -// Dependabot's own ReleaseCooldownOptions (every day value initializes to 0). +// suggested or pinned. DefaultDays applies to every action; RepoOverrides sets +// a per-repo (owner/name) value that wins over the default. Zero DefaultDays +// with no matching override disables cooldown filtering. type CooldownConfig struct { - // DefaultDays is Dependabot's cooldown `default-days`. Zero disables - // cooldown filtering. - DefaultDays int + DefaultDays int + RepoOverrides map[string]int +} + +// CooldownDays returns the cooldown period for a repo, falling back to the default. +func (c CooldownConfig) CooldownDays(owner, repo string) int { + if days, ok := c.RepoOverrides[owner+"/"+repo]; ok { + return days + } + // Override keys may be written with different owner/repo casing than the + // canonicalized lookup; fall back to a case-insensitive match. + want := strings.ToLower(owner + "/" + repo) + for k, days := range c.RepoOverrides { + if strings.ToLower(k) == want { + return days + } + } + return c.DefaultDays +} + +// CooldownDuration returns the cooldown as a time.Duration. +func (c CooldownConfig) CooldownDuration(owner, repo string) time.Duration { + return time.Duration(c.CooldownDays(owner, repo)) * 24 * time.Hour } diff --git a/internal/tag/tags.go b/internal/tag/tags.go index 166b035b..de70dc22 100644 --- a/internal/tag/tags.go +++ b/internal/tag/tags.go @@ -236,7 +236,7 @@ func (tl *Lister) ReleaseDate(owner, repo, tag string) string { // isTagTooNew returns true if the tag's release date is younger than the cooldown period. // Tags without a known release date are never filtered (we can't determine their age). func (tl *Lister) isTagTooNew(owner, repo, tag string) bool { - days := tl.cooldown.DefaultDays + days := tl.cooldown.CooldownDays(owner, repo) if days <= 0 { return false } From de4dfa2f143b8cff3e836b135019c2cf9253a6dd Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 28 Jul 2026 21:44:15 -0500 Subject: [PATCH 4/8] cooldown: don't let a repo's default-days<=0 downgrade a stricter policy A github-actions cooldown block only counts as configured when it sets a positive default-days. A block with default-days<=0 (or only unsupported keys) now falls through to the user's config file or the fresh-tag warn instead of silently overriding a stricter operator policy and suppressing the freshness nudge. Unsupported-key warnings still surface on fall-through. Also drop the unused CooldownDuration method flagged by review. --- cmd/gh-actions-lock/fresh_tag.go | 11 ++-- cmd/gh-actions-lock/resolve_cooldown_test.go | 53 ++++++++++++++++++++ internal/config/config.go | 11 ++-- internal/config/config_test.go | 5 +- internal/tag/cooldown.go | 10 +--- 5 files changed, 72 insertions(+), 18 deletions(-) diff --git a/cmd/gh-actions-lock/fresh_tag.go b/cmd/gh-actions-lock/fresh_tag.go index 8b51cc38..c36e29db 100644 --- a/cmd/gh-actions-lock/fresh_tag.go +++ b/cmd/gh-actions-lock/fresh_tag.go @@ -14,10 +14,13 @@ import ( ) // ResolveCooldown picks the release-cooldown policy by precedence: the repo's -// Dependabot config is gospel; else the user's ~/.config/gh-actions-lock -// config file; else no policy. configured is false only in that last case, so -// the caller applies the global fresh-tag warn instead of a silent filter. -// warnings carries any Dependabot keys we don't honor (surfaced non-blocking). +// Dependabot config is gospel when it sets a positive default-days; else the +// user's ~/.config/gh-actions-lock config file; else no policy. A Dependabot +// cooldown of default-days <= 0 does not count as configured, so a repo can't +// use it to silently override a stricter policy from the user's file. configured +// is false only when no source sets a policy, so the caller applies the global +// fresh-tag warn instead of a silent filter. warnings carries any Dependabot +// keys we don't honor (surfaced non-blocking). func ResolveCooldown(repoRoot string) (cfg tag.CooldownConfig, configured bool, warnings []string) { cfg, ok, warnings := config.DependabotCooldown(repoRoot) if ok { diff --git a/cmd/gh-actions-lock/resolve_cooldown_test.go b/cmd/gh-actions-lock/resolve_cooldown_test.go index 9864b5c7..a42891aa 100644 --- a/cmd/gh-actions-lock/resolve_cooldown_test.go +++ b/cmd/gh-actions-lock/resolve_cooldown_test.go @@ -70,6 +70,59 @@ func TestResolveCooldown_MalformedDependabotFallsBackAndWarns(t *testing.T) { } } +func TestResolveCooldown_DependabotZeroCannotDowngradeFile(t *testing.T) { + // A repo's default-days: 0 must NOT override the operator's stricter file + // policy — it falls through instead of silently disabling cooldown. + writeUserFileConfig(t, "cooldown_days: 14\n") + dir := writeRepoDependabot(t, ` +version: 2 +updates: + - package-ecosystem: "github-actions" + cooldown: + default-days: 0 +`) + cfg, configured, _ := ResolveCooldown(dir) + if !configured || cfg.DefaultDays != 14 { + t.Fatalf("got cfg=%+v configured=%v, want DefaultDays 14 (file, not the repo's 0)", cfg, configured) + } +} + +func TestResolveCooldown_DependabotZeroAloneIsUnconfigured(t *testing.T) { + // default-days: 0 with no file policy resolves to unconfigured, so the + // fresh-tag warn fires rather than a silent no-filter. + t.Setenv("GH_ACTIONS_LOCK_CONFIG", filepath.Join(t.TempDir(), "none.yml")) + dir := writeRepoDependabot(t, ` +version: 2 +updates: + - package-ecosystem: "github-actions" + cooldown: + default-days: 0 +`) + if _, configured, _ := ResolveCooldown(dir); configured { + t.Error("configured = true, want false (default-days 0 is not a policy)") + } +} + +func TestResolveCooldown_UnsupportedKeysOnlyFallsThroughButWarns(t *testing.T) { + // A cooldown block with only unsupported keys (no default-days) applies no + // filter, falls through to the file policy, and still surfaces the warning. + writeUserFileConfig(t, "cooldown_days: 8\n") + dir := writeRepoDependabot(t, ` +version: 2 +updates: + - package-ecosystem: "github-actions" + cooldown: + semver-major-days: 30 +`) + cfg, configured, warnings := ResolveCooldown(dir) + if !configured || cfg.DefaultDays != 8 { + t.Errorf("got cfg=%+v configured=%v, want DefaultDays 8 (file)", cfg, configured) + } + if len(warnings) != 1 { + t.Errorf("warnings = %v, want 1 (unsupported semver key)", warnings) + } +} + // writeUserFileConfig points GH_ACTIONS_LOCK_CONFIG at a temp config.yml. func writeUserFileConfig(t *testing.T, body string) { t.Helper() diff --git a/internal/config/config.go b/internal/config/config.go index ba0b1953..5d297534 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,9 +15,12 @@ import ( // DependabotCooldown reads the github-actions cooldown policy from repoRoot's // Dependabot config (.github/dependabot.yml, falling back to .yaml). ok is true -// only when a github-actions entry carries a cooldown block. warnings names -// configured keys the tool does not yet honor, so config is never silently -// ignored. An absent or malformed config yields ok=false and never blocks. +// only when a github-actions entry configures a positive default-days; a block +// with default-days <= 0 (or only unsupported keys) is treated as not +// configured so it can't silently override a stricter policy from another +// source. warnings names configured keys the tool does not yet honor, so config +// is never silently ignored. An absent or malformed config yields ok=false and +// never blocks. func DependabotCooldown(repoRoot string) (cfg tag.CooldownConfig, ok bool, warnings []string) { data, found := readDependabotFile(repoRoot) if !found { @@ -33,7 +36,7 @@ func DependabotCooldown(repoRoot string) (cfg tag.CooldownConfig, ok bool, warni if !has { return tag.CooldownConfig{}, false, nil } - return tag.CooldownConfig{DefaultDays: cd.DefaultDays}, true, cd.unsupportedWarnings() + return tag.CooldownConfig{DefaultDays: cd.DefaultDays}, cd.DefaultDays > 0, cd.unsupportedWarnings() } func readDependabotFile(repoRoot string) ([]byte, bool) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0c1d4045..d0a4dc59 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -148,10 +148,13 @@ updates: cooldown: default-days: 0 `) - cfg, _, warnings := DependabotCooldown(dir) + cfg, ok, warnings := DependabotCooldown(dir) if cfg.DefaultDays != 0 { t.Errorf("DefaultDays = %d, want 0", cfg.DefaultDays) } + if ok { + t.Error("ok = true, want false (default-days 0 must not count as configured)") + } if len(warnings) != 0 { t.Errorf("explicit 0 is not an unsupported key; warnings = %v", warnings) } diff --git a/internal/tag/cooldown.go b/internal/tag/cooldown.go index 82f8d3cc..b7a0e877 100644 --- a/internal/tag/cooldown.go +++ b/internal/tag/cooldown.go @@ -2,10 +2,7 @@ // cooldown. package tag -import ( - "strings" - "time" -) +import "strings" // CooldownConfig controls the minimum age a tag must have before it's // suggested or pinned. DefaultDays applies to every action; RepoOverrides sets @@ -31,8 +28,3 @@ func (c CooldownConfig) CooldownDays(owner, repo string) int { } return c.DefaultDays } - -// CooldownDuration returns the cooldown as a time.Duration. -func (c CooldownConfig) CooldownDuration(owner, repo string) time.Duration { - return time.Duration(c.CooldownDays(owner, repo)) * 24 * time.Hour -} From eb411a06e6adce80ac62ff96c70f777898804551 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 28 Jul 2026 21:58:08 -0500 Subject: [PATCH 5/8] tests: catalog stub scenarios for cooldown fresh-tag precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three needs_stub scenarios exercising the release-cooldown source chain end to end against the real binary via --json=findings: - fresh_tag_warns_without_cooldown: no dependabot.yml, a tag released <3 days ago produces a fresh-tag finding. - fresh_tag_suppressed_by_cooldown: github-actions cooldown default-days:30 suppresses the finding. - fresh_tag_zero_days_still_warns: default-days:0 cannot suppress it — the security guard against a silent policy downgrade, as an E2E test. The release published_at is computed relative to now in a new wire_checkout_fresh stub helper; a hardcoded date would rot out of the 3-day window and make the assertion time-fragile. Adds a new cooldown category and StdoutExcludes to the Go catalog schema. --- test/integration/run.rb | 27 ++++++++++++++ test/scenarios/catalog.go | 1 + test/scenarios/catalog.yml | 72 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) diff --git a/test/integration/run.rb b/test/integration/run.rb index 16b79813..233ff476 100644 --- a/test/integration/run.rb +++ b/test/integration/run.rb @@ -606,12 +606,39 @@ def wire_checkout_success(s, token) s.env("GH_TOKEN" => token) end +# wire_checkout_fresh stubs a checkout pin whose v4 release published_at is +# computed relative to now, so the tag is genuinely within the 3-day freshness +# window every time the suite runs (a hardcoded date would rot). The scenario's +# .github/dependabot.yml (laid via catalog fixtures.files) selects whether the +# fresh-tag warning fires or is suppressed. +def wire_checkout_fresh(s, token) + s.stub_server do |srv| + checkout_graphql_success(srv) + checkout_repo_rest(srv) + one_day_ago = (Time.now - 86_400).utc.strftime("%Y-%m-%dT%H:%M:%SZ") + srv.on(:GET, %r{/repos/actions/checkout/releases}) do |_req| + [200, { "Content-Type" => "application/json" }, + JSON.generate([{ tag_name: "v4", published_at: one_day_ago, immutable: false }])] + end + end + s.env("GH_TOKEN" => token) +end + # ── Stub server wiring per scenario ──────────────────────────────────── # # Maps scenario names to blocks that wire up the stub server. Scenarios # not listed here either use no stub or rely on catalog-level config. STUB_WIRING = { + fresh_tag_warns_without_cooldown: ->(s) { + wire_checkout_fresh(s, "gho_fake_fresh_warn_token") + }, + fresh_tag_suppressed_by_cooldown: ->(s) { + wire_checkout_fresh(s, "gho_fake_fresh_suppressed_token") + }, + fresh_tag_zero_days_still_warns: ->(s) { + wire_checkout_fresh(s, "gho_fake_fresh_zero_token") + }, migrate_local_actions_rewrite: ->(s) { wire_checkout_success(s, "gho_fake_migrate_token") }, diff --git a/test/scenarios/catalog.go b/test/scenarios/catalog.go index e2989785..aa8a60b2 100644 --- a/test/scenarios/catalog.go +++ b/test/scenarios/catalog.go @@ -80,6 +80,7 @@ type Expect struct { OutputContains []string `yaml:"output_contains"` OutputExcludes []string `yaml:"output_excludes"` StdoutContains []string `yaml:"stdout_contains"` + StdoutExcludes []string `yaml:"stdout_excludes,omitempty"` StdoutIsJSON bool `yaml:"stdout_is_json"` LockfileExists bool `yaml:"lockfile_exists"` // LockfileExcludes asserts the generated lockfile does NOT contain each diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index 886bfe0a..994faae5 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -45,6 +45,8 @@ categories: description: "--no-onboard gating, onboarding-required findings, and CI flag composition" - name: dependabot description: "Dependabot consumer contract — JSON findings shape, exit codes, and category/severity for --no-onboard --no-narrow --no-interactive" + - name: cooldown + description: "Release-cooldown sourcing from Dependabot config and the fresh-tag freshness nudge" scenarios: # ╔═════════════════════════════════════════════════════════════════════════╗ @@ -2097,3 +2099,73 @@ scenarios: - "repo_id:" lockfile_comment_excludes: '^\s+tag:' lockfile_comment_matches: "ref: '" + + - name: fresh_tag_warns_without_cooldown + category: cooldown + description: "No Dependabot cooldown configured: pinning a tag released <3 days ago emits a fresh-tag finding" + needs_stub: true + tags: [stub] + flags: ["--no-narrow", "--json=findings"] + fixtures: + workflows: + ci.yml: + name: CI + actions: ["actions/checkout@v4"] + expect: + stdout_is_json: true + stdout_contains: + - "fresh-tag" + - "pinned to a very recent tag with no Dependabot cooldown configured" + + - name: fresh_tag_suppressed_by_cooldown + category: cooldown + description: "Dependabot cooldown default-days>0 suppresses the fresh-tag finding" + needs_stub: true + tags: [stub] + flags: ["--no-narrow", "--json=findings"] + fixtures: + workflows: + ci.yml: + name: CI + actions: ["actions/checkout@v4"] + files: + .github/dependabot.yml: | + version: 2 + updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 30 + expect: + stdout_is_json: true + stdout_excludes: + - "fresh-tag" + + - name: fresh_tag_zero_days_still_warns + category: cooldown + description: "Dependabot cooldown default-days:0 cannot suppress the fresh-tag finding (no silent downgrade)" + needs_stub: true + tags: [stub] + flags: ["--no-narrow", "--json=findings"] + fixtures: + workflows: + ci.yml: + name: CI + actions: ["actions/checkout@v4"] + files: + .github/dependabot.yml: | + version: 2 + updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 0 + expect: + stdout_is_json: true + stdout_contains: + - "fresh-tag" + - "pinned to a very recent tag with no Dependabot cooldown configured" From 319562f8a5b832c8a05862bfadfcf68f26792d12 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Tue, 28 Jul 2026 22:14:35 -0500 Subject: [PATCH 6/8] fresh-tag: surface the nudge on a clean pin, not just in --json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fresh-tag and cooldown-ignored findings were only rendered by PresentResults, which in fix mode runs before injection during diagnosis and afterward only when an unfixable error is present. On an ordinary clean pin — initial lock or an update that changed a SHA — the warning reached --json and the run log but never the terminal, so users never saw it. Give renderPinSummary sole ownership: it now renders these two categories on every fix-mode run (PresentResults skips them to avoid a double print). Both initial pins and updates produce Pinned entries, so both now warn; an unchanged re-run is Verified and stays quiet. Add a Go test for injectFreshTagFindings (Pinned fires, Verified quiet) and switch the catalog fresh_tag_warns_without_cooldown scenario to a fix-mode terminal assertion so it guards the surface end to end. Also trims the fresh_tag.go doc comments. --- cmd/gh-actions-lock/format/terminal.go | 5 +++ cmd/gh-actions-lock/fresh_tag.go | 35 +++++++------------- cmd/gh-actions-lock/freshtag_test.go | 46 ++++++++++++++++++++++++++ cmd/gh-actions-lock/pin_summary.go | 13 ++++++++ test/scenarios/catalog.yml | 9 +++-- 5 files changed, 80 insertions(+), 28 deletions(-) diff --git a/cmd/gh-actions-lock/format/terminal.go b/cmd/gh-actions-lock/format/terminal.go index d668355f..93026b22 100644 --- a/cmd/gh-actions-lock/format/terminal.go +++ b/cmd/gh-actions-lock/format/terminal.go @@ -24,6 +24,11 @@ func PresentResults(out *ui.UI, report *checks.Report, valid bool, willRemediate exclude[c] = true } for _, f := range report.RepoFindings { + // Fresh-tag and cooldown-ignored nudges are rendered by the fix-mode + // pin summary, which owns them so they show on a clean pin too. + if f.Category == checks.FreshTag || f.Category == checks.CooldownConfigIgnored { + continue + } out.TermWarn("%s", f.Detail) if f.DocURL != "" { out.TermDetail("see: %s", out.TermLink(f.DocURL, f.DocURL)) diff --git a/cmd/gh-actions-lock/fresh_tag.go b/cmd/gh-actions-lock/fresh_tag.go index c36e29db..2b3bead2 100644 --- a/cmd/gh-actions-lock/fresh_tag.go +++ b/cmd/gh-actions-lock/fresh_tag.go @@ -13,14 +13,9 @@ import ( "github.com/github/gh-actions-lock/internal/tag" ) -// ResolveCooldown picks the release-cooldown policy by precedence: the repo's -// Dependabot config is gospel when it sets a positive default-days; else the -// user's ~/.config/gh-actions-lock config file; else no policy. A Dependabot -// cooldown of default-days <= 0 does not count as configured, so a repo can't -// use it to silently override a stricter policy from the user's file. configured -// is false only when no source sets a policy, so the caller applies the global -// fresh-tag warn instead of a silent filter. warnings carries any Dependabot -// keys we don't honor (surfaced non-blocking). +// ResolveCooldown picks the cooldown policy by precedence: Dependabot config +// (only when default-days > 0, so a repo can't downgrade a stricter file policy), +// then the user's config file, then none. func ResolveCooldown(repoRoot string) (cfg tag.CooldownConfig, configured bool, warnings []string) { cfg, ok, warnings := config.DependabotCooldown(repoRoot) if ok { @@ -32,14 +27,12 @@ func ResolveCooldown(repoRoot string) (cfg tag.CooldownConfig, configured bool, return tag.CooldownConfig{}, false, warnings } -// freshTagWindow is the age below which a newly pinned tag is flagged when no -// Dependabot cooldown is configured. Matches the CLI's historical default so -// the nudge fires exactly where the old silent 3-day cooldown used to filter. +// freshTagWindow is how recent a pinned tag must be to get flagged when no +// cooldown is configured. Same 3 days the old silent cooldown used. const freshTagWindow = 3 * 24 * time.Hour -// appendCooldownConfigFindings surfaces Dependabot cooldown keys the tool does -// not honor as non-blocking repo-level findings, so ignored config is never -// silent. +// appendCooldownConfigFindings surfaces Dependabot cooldown keys we don't honor +// as non-blocking findings, so ignored config never goes silent. func appendCooldownConfigFindings(report *checks.Report, warnings []string) { for _, msg := range warnings { report.RepoFindings = append(report.RepoFindings, checks.Finding{ @@ -51,12 +44,9 @@ func appendCooldownConfigFindings(report *checks.Report, warnings []string) { } } -// injectFreshTagFindings warns about newly pinned tags released within -// freshTagWindow when no Dependabot cooldown is configured. It only inspects -// entries pinned this run (Resolution == Pinned) and relies on release dates -// already fetched during resolution, falling back to a single cached tag -// listing per repo. Tags without a GitHub Release have no published_at and are -// not flagged — the same limitation the cooldown filter has always had. +// injectFreshTagFindings flags tags pinned this run that were released within +// freshTagWindow, when no cooldown is configured. Tags without a GitHub Release +// have no date and aren't flagged — same blind spot the cooldown filter has. func injectFreshTagFindings(ctx context.Context, report *checks.Report, record *pin.Record, tagger *tag.Lister) { seen := map[string]bool{} // NWO@tag → already flagged for _, e := range record.Pinned() { @@ -91,9 +81,8 @@ func injectFreshTagFindings(ctx context.Context, report *checks.Report, record * } } -// freshTagFinding builds a fresh-tag finding when iso parses to a release -// within freshTagWindow of now. It reports ok=false for an empty, unparseable, -// or old date so the caller skips it. +// freshTagFinding builds a fresh-tag finding when iso is a release within +// freshTagWindow of now. ok=false for an empty, unparseable, or old date. func freshTagFinding(nwo, tagName, iso string, now time.Time) (checks.Finding, bool) { if iso == "" { return checks.Finding{}, false diff --git a/cmd/gh-actions-lock/freshtag_test.go b/cmd/gh-actions-lock/freshtag_test.go index e650b861..38dd1a05 100644 --- a/cmd/gh-actions-lock/freshtag_test.go +++ b/cmd/gh-actions-lock/freshtag_test.go @@ -1,10 +1,14 @@ package main import ( + "context" "testing" "time" + "github.com/github/gh-actions-lock/internal/ghapi/httpmock" + "github.com/github/gh-actions-lock/internal/pin" "github.com/github/gh-actions-lock/internal/pipeline/checks" + "github.com/github/gh-actions-lock/internal/tag" ) func TestFreshTagFinding(t *testing.T) { @@ -52,3 +56,45 @@ func TestAppendCooldownConfigFindings(t *testing.T) { t.Errorf("nil warnings must add nothing; got %d", len(report.RepoFindings)) } } + +// TestInjectFreshTagFindings covers the resolution gate: a Pinned entry (an +// initial pin or an update that changed the SHA) fires the fresh-tag nudge, +// while a Verified entry (unchanged re-run) stays quiet. +func TestInjectFreshTagFindings(t *testing.T) { + fresh := time.Now().Add(-24 * time.Hour).Format(time.RFC3339) + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", `(?i)repos/actions/checkout/tags`), + httpmock.JSONResponse([]map[string]any{ + {"name": "v4", "commit": map[string]any{"sha": "abc123"}}, + }), + ) + reg.Register( + httpmock.REST("GET", `(?i)repos/actions/checkout/releases`), + httpmock.JSONResponse([]map[string]any{ + {"tag_name": "v4", "published_at": fresh, "immutable": false}, + }), + ) + reg.Register( + httpmock.REST("GET", `(?i)repos/actions/checkout$`), + httpmock.JSONResponse(map[string]any{ + "default_branch": "main", "visibility": "public", + "pushed_at": "2024-01-01T00:00:00Z", + }), + ) + tagger := tag.NewListerForTest(t, reg) + + record := &pin.Record{Entries: []pin.Entry{ + {NWO: "actions/checkout", Ref: "v4", SHA: "s1", Resolution: pin.Pinned}, + {NWO: "actions/setup-go", Ref: "v5", SHA: "s2", Resolution: pin.Verified}, + }} + report := &checks.Report{} + injectFreshTagFindings(context.Background(), report, record, tagger) + + if len(report.RepoFindings) != 1 { + t.Fatalf("RepoFindings = %d, want 1 (Pinned fires, Verified quiet)", len(report.RepoFindings)) + } + if got := report.RepoFindings[0]; got.Category != checks.FreshTag { + t.Errorf("category = %s, want fresh-tag", got.Category) + } +} diff --git a/cmd/gh-actions-lock/pin_summary.go b/cmd/gh-actions-lock/pin_summary.go index 83d3f927..1729afb7 100644 --- a/cmd/gh-actions-lock/pin_summary.go +++ b/cmd/gh-actions-lock/pin_summary.go @@ -83,6 +83,7 @@ func renderPinSummary(ctx context.Context, console *ui.UI, record *pin.Record, r } renderFullScanWarnings(console, pinned) + renderCooldownFindings(console, report) if !noNarrow { renderVersionRefNudge(ctx, console, record, r) } @@ -163,6 +164,18 @@ func renderPinSummary(ctx context.Context, console *ui.UI, record *pin.Record, r return nil } +// renderCooldownFindings surfaces the fresh-tag and cooldown-ignored nudges on +// the terminal. PresentResults skips these categories so this is their single +// surface, shown even on a clean pin where PresentResults never runs. +func renderCooldownFindings(console *ui.UI, report *checks.Report) { + for _, f := range report.RepoFindings { + if f.Category != checks.FreshTag && f.Category != checks.CooldownConfigIgnored { + continue + } + console.TermWarn("%s", f.Detail) + } +} + // renderNarrowedEntries shows refs that were upgraded from mutable (main, v4) // to full semver (v6.0.2) on already-pinned workflows. func renderNarrowedEntries(console *ui.UI, narrowed []pin.Entry) { diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index 994faae5..db3bd052 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -2102,19 +2102,18 @@ scenarios: - name: fresh_tag_warns_without_cooldown category: cooldown - description: "No Dependabot cooldown configured: pinning a tag released <3 days ago emits a fresh-tag finding" + description: "No Dependabot cooldown configured: pinning a tag released <3 days ago warns on the terminal" needs_stub: true tags: [stub] - flags: ["--no-narrow", "--json=findings"] + flags: ["--no-narrow"] fixtures: workflows: ci.yml: name: CI actions: ["actions/checkout@v4"] expect: - stdout_is_json: true - stdout_contains: - - "fresh-tag" + exit: 0 + output_contains: - "pinned to a very recent tag with no Dependabot cooldown configured" - name: fresh_tag_suppressed_by_cooldown From 0b8703bc96be938b2e0d6522f567ed27c5298253 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Wed, 29 Jul 2026 18:54:33 -0500 Subject: [PATCH 7/8] cooldown: table-driven DependabotCooldown tests; restore cliVersion doc Collapse ten shape-identical DependabotCooldown cases into one table (-80 lines); RealWorldFixture and KeyTagsAreDistinct stay standalone since their intent differs. Also restore the cliVersion doc-comment line an earlier edit accidentally orphaned. --- cmd/gh-actions-lock/run.go | 1 + internal/config/config_test.go | 233 +++++++++++---------------------- 2 files changed, 77 insertions(+), 157 deletions(-) diff --git a/cmd/gh-actions-lock/run.go b/cmd/gh-actions-lock/run.go index 587c5437..4ebaaf2d 100644 --- a/cmd/gh-actions-lock/run.go +++ b/cmd/gh-actions-lock/run.go @@ -551,6 +551,7 @@ func appendStaleWorkflowFindings(report *checks.Report, workflows []string, prun // cliVersion returns the gh-actions-lock extension version embedded by the Go // build system. Returns "(devel)" for local `go build` and a real version +// like "v0.1.2" when installed via `gh extension install`. func cliVersion() string { if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" { return info.Main.Version diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d0a4dc59..47a2efc3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -33,163 +33,82 @@ func TestDependabotCooldown_RealWorldFixture(t *testing.T) { } } -func TestDependabotCooldown_PresentWithCooldown(t *testing.T) { - dir := writeDependabot(t, "dependabot.yml", ` -version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - cooldown: - default-days: 5 -`) - cfg, _, warnings := DependabotCooldown(dir) - if cfg.DefaultDays != 5 { - t.Errorf("DefaultDays = %d, want 5", cfg.DefaultDays) - } - if len(warnings) != 0 { - t.Errorf("warnings = %v, want none", warnings) - } -} - -func TestDependabotCooldown_YAMLExtension(t *testing.T) { - dir := writeDependabot(t, "dependabot.yaml", ` -version: 2 -updates: - - package-ecosystem: "github-actions" - cooldown: - default-days: 2 -`) - cfg, _, _ := DependabotCooldown(dir) - if cfg.DefaultDays != 2 { - t.Errorf("DefaultDays = %d, want 2 (.yaml must be read)", cfg.DefaultDays) - } -} - -func TestDependabotCooldown_PresentWithoutCooldownBlock(t *testing.T) { - dir := writeDependabot(t, "dependabot.yml", ` -version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" -`) - cfg, _, warnings := DependabotCooldown(dir) - if cfg.DefaultDays != 0 { - t.Errorf("DefaultDays = %d, want 0 (no cooldown block)", cfg.DefaultDays) - } - if len(warnings) != 0 { - t.Errorf("warnings = %v, want none", warnings) - } -} - -func TestDependabotCooldown_OtherEcosystemIgnored(t *testing.T) { - dir := writeDependabot(t, "dependabot.yml", ` -version: 2 -updates: - - package-ecosystem: "npm" - cooldown: - default-days: 9 -`) - cfg, _, _ := DependabotCooldown(dir) - if cfg.DefaultDays != 0 { - t.Errorf("DefaultDays = %d, want 0 (only github-actions counts)", cfg.DefaultDays) - } -} - -func TestDependabotCooldown_Absent(t *testing.T) { - cfg, _, warnings := DependabotCooldown(t.TempDir()) - if cfg.DefaultDays != 0 { - t.Errorf("DefaultDays = %d, want 0 (no config file)", cfg.DefaultDays) - } - if len(warnings) != 0 { - t.Errorf("warnings = %v, want none", warnings) - } -} - -func TestDependabotCooldown_Malformed(t *testing.T) { - dir := writeDependabot(t, "dependabot.yml", "updates: [this: is: not: valid") - cfg, _, warnings := DependabotCooldown(dir) - if cfg.DefaultDays != 0 { - t.Errorf("DefaultDays = %d, want 0 (malformed must not block)", cfg.DefaultDays) - } - if len(warnings) != 1 { - t.Fatalf("warnings = %v, want one malformed-config warning", warnings) - } -} - -func TestDependabotCooldown_UnsupportedKeysWarn(t *testing.T) { - dir := writeDependabot(t, "dependabot.yml", ` -version: 2 -updates: - - package-ecosystem: "github-actions" - cooldown: - default-days: 3 - semver-major-days: 30 - include: - - "actions/*" -`) - cfg, _, warnings := DependabotCooldown(dir) - if cfg.DefaultDays != 3 { - t.Errorf("DefaultDays = %d, want 3", cfg.DefaultDays) - } - if len(warnings) != 2 { - t.Fatalf("warnings = %v, want 2 (semver-* and include/exclude)", warnings) - } -} - -func TestDependabotCooldown_ExplicitZeroDays(t *testing.T) { - dir := writeDependabot(t, "dependabot.yml", ` -version: 2 -updates: - - package-ecosystem: "github-actions" - cooldown: - default-days: 0 -`) - cfg, ok, warnings := DependabotCooldown(dir) - if cfg.DefaultDays != 0 { - t.Errorf("DefaultDays = %d, want 0", cfg.DefaultDays) - } - if ok { - t.Error("ok = true, want false (default-days 0 must not count as configured)") - } - if len(warnings) != 0 { - t.Errorf("explicit 0 is not an unsupported key; warnings = %v", warnings) - } -} - -func TestDependabotCooldown_EmptyCooldownBlock(t *testing.T) { - // `cooldown:` with no mapping parses to a nil pointer, i.e. no block. - dir := writeDependabot(t, "dependabot.yml", ` -version: 2 -updates: - - package-ecosystem: "github-actions" - cooldown: -`) - cfg, _, warnings := DependabotCooldown(dir) - if cfg.DefaultDays != 0 || len(warnings) != 0 { - t.Errorf("empty cooldown: got DefaultDays=%d warnings=%v, want 0 / none", cfg.DefaultDays, warnings) - } -} - -func TestDependabotCooldown_FirstActionsEntryWithCooldownWins(t *testing.T) { - // Multi-directory setups can list github-actions twice; take the first - // entry that actually carries a cooldown block. - dir := writeDependabot(t, "dependabot.yml", ` -version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" - - package-ecosystem: "github-actions" - directory: "/nested" - cooldown: - default-days: 4 -`) - cfg, _, _ := DependabotCooldown(dir) - if cfg.DefaultDays != 4 { - t.Errorf("DefaultDays = %d, want 4 (first entry with a cooldown block)", cfg.DefaultDays) +func TestDependabotCooldown_Table(t *testing.T) { + tests := []struct { + name string + filename string // default dependabot.yml; empty means write no file + yaml string + wantDays int + wantOK bool + wantWarn int + }{ + { + name: "present with cooldown", + yaml: "version: 2\nupdates:\n - package-ecosystem: \"github-actions\"\n directory: \"/\"\n schedule:\n interval: \"weekly\"\n cooldown:\n default-days: 5\n", + wantDays: 5, wantOK: true, + }, + { + name: "yaml extension is read", + filename: "dependabot.yaml", + yaml: "version: 2\nupdates:\n - package-ecosystem: \"github-actions\"\n cooldown:\n default-days: 2\n", + wantDays: 2, wantOK: true, + }, + { + name: "present without cooldown block", + yaml: "version: 2\nupdates:\n - package-ecosystem: \"github-actions\"\n directory: \"/\"\n schedule:\n interval: \"weekly\"\n", + }, + { + name: "other ecosystem ignored", + yaml: "version: 2\nupdates:\n - package-ecosystem: \"npm\"\n cooldown:\n default-days: 9\n", + }, + { + name: "absent config file", + }, + { + name: "malformed does not block", + yaml: "updates: [this: is: not: valid", + wantWarn: 1, + }, + { + name: "unsupported keys warn", + yaml: "version: 2\nupdates:\n - package-ecosystem: \"github-actions\"\n cooldown:\n default-days: 3\n semver-major-days: 30\n include:\n - \"actions/*\"\n", + wantDays: 3, wantOK: true, wantWarn: 2, + }, + { + name: "explicit zero is not configured", + yaml: "version: 2\nupdates:\n - package-ecosystem: \"github-actions\"\n cooldown:\n default-days: 0\n", + }, + { + name: "empty cooldown block", + yaml: "version: 2\nupdates:\n - package-ecosystem: \"github-actions\"\n cooldown:\n", + }, + { + name: "first actions entry with a cooldown wins", + yaml: "version: 2\nupdates:\n - package-ecosystem: \"github-actions\"\n directory: \"/\"\n - package-ecosystem: \"github-actions\"\n directory: \"/nested\"\n cooldown:\n default-days: 4\n", + wantDays: 4, wantOK: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + if tt.yaml != "" { + name := tt.filename + if name == "" { + name = "dependabot.yml" + } + dir = writeDependabot(t, name, tt.yaml) + } + cfg, ok, warnings := DependabotCooldown(dir) + if cfg.DefaultDays != tt.wantDays { + t.Errorf("DefaultDays = %d, want %d", cfg.DefaultDays, tt.wantDays) + } + if ok != tt.wantOK { + t.Errorf("ok = %v, want %v", ok, tt.wantOK) + } + if len(warnings) != tt.wantWarn { + t.Errorf("warnings = %v, want %d", warnings, tt.wantWarn) + } + }) } } From 7c843f099cd8ed4d8dd367ee4e55f5abe90d344a Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Wed, 29 Jul 2026 20:43:26 -0500 Subject: [PATCH 8/8] =?UTF-8?q?cooldown:=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20read-only=20surfacing,=20per-entry=20gate,=20messag?= =?UTF-8?q?e=20accuracy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes from the automated PR review: - Surface unsupported/malformed cooldown warnings before the --no-fix/--verify early return so read-only runs report them in both JSON and terminal instead of silently dropping them. Classify fresh-tag and cooldown-config-ignored as non-blocking in Finding.IsValid so they never flip a run to invalid. - Gate the fresh-tag nudge per action on its effective cooldown rather than a single global flag, so a repo whose effective cooldown is 0 still gets nudged even when another action configures one. - Reword the ignored-key warnings to state the keys are ignored instead of claiming default-days is applied, which is false when only unsupported keys are set. - Point the fresh-tag stub scenarios at an empty GH_ACTIONS_LOCK_CONFIG so they can't read a developer's real user config and become environment-dependent. Adds a Go test for the per-entry skip and a catalog scenario proving the read-only warning surfaces. --- cmd/gh-actions-lock/format/terminal.go | 6 +++--- cmd/gh-actions-lock/fresh_tag.go | 11 +++++++--- cmd/gh-actions-lock/freshtag_test.go | 28 +++++++++++++++++++++++++- cmd/gh-actions-lock/pin_summary.go | 8 ++++---- cmd/gh-actions-lock/run.go | 17 +++++++++------- internal/config/cooldown.go | 4 ++-- internal/pipeline/checks/finding.go | 2 +- test/integration/run.rb | 6 ++++++ test/scenarios/catalog.yml | 26 ++++++++++++++++++++++++ 9 files changed, 87 insertions(+), 21 deletions(-) diff --git a/cmd/gh-actions-lock/format/terminal.go b/cmd/gh-actions-lock/format/terminal.go index 93026b22..818bee11 100644 --- a/cmd/gh-actions-lock/format/terminal.go +++ b/cmd/gh-actions-lock/format/terminal.go @@ -24,9 +24,9 @@ func PresentResults(out *ui.UI, report *checks.Report, valid bool, willRemediate exclude[c] = true } for _, f := range report.RepoFindings { - // Fresh-tag and cooldown-ignored nudges are rendered by the fix-mode - // pin summary, which owns them so they show on a clean pin too. - if f.Category == checks.FreshTag || f.Category == checks.CooldownConfigIgnored { + // Fresh-tag nudges are appended after this render and shown by the + // fix-mode pin summary, so they surface on a clean pin too. + if f.Category == checks.FreshTag { continue } out.TermWarn("%s", f.Detail) diff --git a/cmd/gh-actions-lock/fresh_tag.go b/cmd/gh-actions-lock/fresh_tag.go index 2b3bead2..6a905e24 100644 --- a/cmd/gh-actions-lock/fresh_tag.go +++ b/cmd/gh-actions-lock/fresh_tag.go @@ -45,15 +45,20 @@ func appendCooldownConfigFindings(report *checks.Report, warnings []string) { } // injectFreshTagFindings flags tags pinned this run that were released within -// freshTagWindow, when no cooldown is configured. Tags without a GitHub Release -// have no date and aren't flagged — same blind spot the cooldown filter has. -func injectFreshTagFindings(ctx context.Context, report *checks.Report, record *pin.Record, tagger *tag.Lister) { +// freshTagWindow, for actions whose effective cooldown is 0 (so the resolver's +// cooldown filter didn't already gate them). An action with a positive cooldown +// is skipped: fresh releases were filtered during narrowing. Tags without a +// GitHub Release have no date and aren't flagged. +func injectFreshTagFindings(ctx context.Context, report *checks.Report, record *pin.Record, tagger *tag.Lister, cooldownCfg tag.CooldownConfig) { seen := map[string]bool{} // NWO@tag → already flagged for _, e := range record.Pinned() { owner, repo, ok := strings.Cut(e.NWO, "/") if !ok { continue } + if cooldownCfg.CooldownDays(owner, repo) > 0 { + continue // resolver already filtered fresh tags for this action + } tagName := e.Tag if tagName == "" { tagName = e.Ref diff --git a/cmd/gh-actions-lock/freshtag_test.go b/cmd/gh-actions-lock/freshtag_test.go index 38dd1a05..d7caabaf 100644 --- a/cmd/gh-actions-lock/freshtag_test.go +++ b/cmd/gh-actions-lock/freshtag_test.go @@ -11,6 +11,32 @@ import ( "github.com/github/gh-actions-lock/internal/tag" ) +// TestInjectFreshTagFindings_PositiveCooldownSkips proves the per-entry gate: +// an action with a positive effective cooldown is not nudged, because the +// resolver's cooldown filter already gated fresh tags for it. +func TestInjectFreshTagFindings_PositiveCooldownSkips(t *testing.T) { + fresh := time.Now().Add(-24 * time.Hour).Format(time.RFC3339) + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", `(?i)repos/actions/checkout/releases`), + httpmock.JSONResponse([]map[string]any{ + {"tag_name": "v4", "published_at": fresh, "immutable": false}, + }), + ) + tagger := tag.NewListerForTest(t, reg) + + record := &pin.Record{Entries: []pin.Entry{ + {NWO: "actions/checkout", Ref: "v4", SHA: "s1", Resolution: pin.Pinned}, + }} + report := &checks.Report{} + cfg := tag.CooldownConfig{RepoOverrides: map[string]int{"actions/checkout": 7}} + injectFreshTagFindings(context.Background(), report, record, tagger, cfg) + + if len(report.RepoFindings) != 0 { + t.Fatalf("RepoFindings = %d, want 0 (positive cooldown skips the nudge)", len(report.RepoFindings)) + } +} + func TestFreshTagFinding(t *testing.T) { now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) iso := func(d time.Duration) string { return now.Add(-d).Format(time.RFC3339) } @@ -89,7 +115,7 @@ func TestInjectFreshTagFindings(t *testing.T) { {NWO: "actions/setup-go", Ref: "v5", SHA: "s2", Resolution: pin.Verified}, }} report := &checks.Report{} - injectFreshTagFindings(context.Background(), report, record, tagger) + injectFreshTagFindings(context.Background(), report, record, tagger, tag.CooldownConfig{}) if len(report.RepoFindings) != 1 { t.Fatalf("RepoFindings = %d, want 1 (Pinned fires, Verified quiet)", len(report.RepoFindings)) diff --git a/cmd/gh-actions-lock/pin_summary.go b/cmd/gh-actions-lock/pin_summary.go index 1729afb7..c7f9bc28 100644 --- a/cmd/gh-actions-lock/pin_summary.go +++ b/cmd/gh-actions-lock/pin_summary.go @@ -164,12 +164,12 @@ func renderPinSummary(ctx context.Context, console *ui.UI, record *pin.Record, r return nil } -// renderCooldownFindings surfaces the fresh-tag and cooldown-ignored nudges on -// the terminal. PresentResults skips these categories so this is their single -// surface, shown even on a clean pin where PresentResults never runs. +// renderCooldownFindings surfaces the fresh-tag nudge on the terminal in fix +// mode, so it shows even on a clean pin where PresentResults renders nothing. +// Cooldown-ignored notices are surfaced earlier by PresentResults (both modes). func renderCooldownFindings(console *ui.UI, report *checks.Report) { for _, f := range report.RepoFindings { - if f.Category != checks.FreshTag && f.Category != checks.CooldownConfigIgnored { + if f.Category != checks.FreshTag { continue } console.TermWarn("%s", f.Detail) diff --git a/cmd/gh-actions-lock/run.go b/cmd/gh-actions-lock/run.go index 4ebaaf2d..0b7a3d32 100644 --- a/cmd/gh-actions-lock/run.go +++ b/cmd/gh-actions-lock/run.go @@ -246,10 +246,9 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) // Build a Lister for pin narrowing, reusing the resolver's API client; cooldown is resolved dependabot > config file > none. var tagger *tag.Lister var cooldownCfg tag.CooldownConfig - var cooldownConfigured bool var cooldownWarnings []string if gc := r.GHClient(); gc != nil { - cooldownCfg, cooldownConfigured, cooldownWarnings = ResolveCooldown(".") + cooldownCfg, _, cooldownWarnings = ResolveCooldown(".") tagger = tag.NewLister(gc, cooldownCfg) } @@ -310,6 +309,10 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) } } + // Surface Dependabot cooldown keys we don't honor before the diagnosis so + // they reach both read-only (--no-fix/--verify) and fix runs. Non-blocking. + appendCooldownConfigFindings(report, cooldownWarnings) + // Render the read-only diagnosis. --json selects the renderer; it does // not decide whether fixes are applied. Terminal output is shown up front // (the human narrative). JSON is emitted later, after any fixes land, so @@ -416,11 +419,11 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) injectVersionRefFindings(report, record) } - // Surface Dependabot cooldown keys we don't honor, and nudge on freshly - // pinned tags when no cooldown policy is configured. Both are non-blocking. - appendCooldownConfigFindings(report, cooldownWarnings) - if tagger != nil && !cooldownConfigured { - injectFreshTagFindings(ctx, report, record, tagger) + // Nudge on freshly pinned tags for actions with no effective cooldown. + // Non-blocking; per-entry so a global "configured" flag can't mask an + // action whose effective cooldown is 0. + if tagger != nil { + injectFreshTagFindings(ctx, report, record, tagger, cooldownCfg) } // Write the run log. diff --git a/internal/config/cooldown.go b/internal/config/cooldown.go index 7c38ba40..d3d782cb 100644 --- a/internal/config/cooldown.go +++ b/internal/config/cooldown.go @@ -34,10 +34,10 @@ func (c dependabotConfig) actionsCooldown() (cooldown, bool) { func (c cooldown) unsupportedWarnings() []string { var w []string if c.SemverMajorDays > 0 || c.SemverMinorDays > 0 || c.SemverPatchDays > 0 { - w = append(w, "Dependabot cooldown semver-major/minor/patch-days are not supported; applying default-days to all upgrades") + w = append(w, "Dependabot cooldown semver-major/minor/patch-days are not supported and were ignored") } if len(c.Include) > 0 || len(c.Exclude) > 0 { - w = append(w, "Dependabot cooldown include/exclude filters are not supported; applying default-days to all actions") + w = append(w, "Dependabot cooldown include/exclude filters are not supported and were ignored") } return w } diff --git a/internal/pipeline/checks/finding.go b/internal/pipeline/checks/finding.go index af9debe1..12d7b488 100644 --- a/internal/pipeline/checks/finding.go +++ b/internal/pipeline/checks/finding.go @@ -112,7 +112,7 @@ func (f *Finding) IsValid() bool { return true } switch f.Category { - case Valid, RunOnly, LocalAction, ShaAsRef, RefMoved, VersionRef, OnboardingRequired, StaleWorkflow, SelfRepositoryAction: + case Valid, RunOnly, LocalAction, ShaAsRef, RefMoved, VersionRef, OnboardingRequired, StaleWorkflow, SelfRepositoryAction, FreshTag, CooldownConfigIgnored: return true case NotPinned: return f.ActionRef == nil // workflow-level is a warning diff --git a/test/integration/run.rb b/test/integration/run.rb index 233ff476..00391db9 100644 --- a/test/integration/run.rb +++ b/test/integration/run.rb @@ -622,6 +622,9 @@ def wire_checkout_fresh(s, token) end end s.env("GH_TOKEN" => token) + # Pin the user config path at an empty file so the subprocess can't read a + # developer's real ~/.config/gh-actions-lock/config.yml and suppress the nudge. + s.env("GH_ACTIONS_LOCK_CONFIG" => "/dev/null") end # ── Stub server wiring per scenario ──────────────────────────────────── @@ -639,6 +642,9 @@ def wire_checkout_fresh(s, token) fresh_tag_zero_days_still_warns: ->(s) { wire_checkout_fresh(s, "gho_fake_fresh_zero_token") }, + cooldown_ignored_keys_surface_in_readonly: ->(s) { + wire_checkout_success(s, "gho_fake_cooldown_ignored_token") + }, migrate_local_actions_rewrite: ->(s) { wire_checkout_success(s, "gho_fake_migrate_token") }, diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index db3bd052..8d327b1f 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -2168,3 +2168,29 @@ scenarios: stdout_contains: - "fresh-tag" - "pinned to a very recent tag with no Dependabot cooldown configured" + + - name: cooldown_ignored_keys_surface_in_readonly + category: cooldown + description: "Read-only (--no-fix): unsupported Dependabot cooldown keys surface in JSON even when the run fails on an unpinned action" + needs_stub: true + tags: [stub] + flags: ["--no-fix", "--no-narrow", "--json=findings"] + fixtures: + workflows: + ci.yml: + name: CI + actions: ["actions/checkout@v4"] + files: + .github/dependabot.yml: | + version: 2 + updates: + - package-ecosystem: "github-actions" + directory: "/" + cooldown: + semver-major-days: 30 + expect: + exit: 1 + stdout_is_json: true + stdout_contains: + - "cooldown-config-ignored" + - "not supported and were ignored"