diff --git a/.github/workflows/agentic-auto-upgrade.yml b/.github/workflows/agentic-auto-upgrade.yml index 6aff9d33647..f29148c20d4 100644 --- a/.github/workflows/agentic-auto-upgrade.yml +++ b/.github/workflows/agentic-auto-upgrade.yml @@ -27,14 +27,14 @@ # Or use the gh-aw CLI directly: # ./gh-aw compile --validate --verbose # -# The workflow is generated when auto_upgrade is set to true in aw.json. -# The weekly schedule is deterministically scattered based on the repository slug. +# The workflow is generated when auto_upgrade.cron is set in aw.json. +# The schedule is pinned to the custom cron expression configured in aw.json. # name: Agentic Auto-Upgrade on: schedule: - - cron: "41 23 * * 1" # Weekly (auto-upgrade) + - cron: "0 9 * * 1" # Custom schedule (auto-upgrade) workflow_dispatch: permissions: diff --git a/.github/workflows/aw.json b/.github/workflows/aw.json index eb2cac13e0a..b9f4da9915f 100644 --- a/.github/workflows/aw.json +++ b/.github/workflows/aw.json @@ -1,5 +1,5 @@ { - "auto_upgrade": true, + "auto_upgrade": { "cron": "0 9 * * 1" }, "ghes": true, "maintenance": { "action_failure_issue_expires": 12, diff --git a/docs/adr/46521-configurable-auto-upgrade-cron-schedule.md b/docs/adr/46521-configurable-auto-upgrade-cron-schedule.md new file mode 100644 index 00000000000..48960e5574c --- /dev/null +++ b/docs/adr/46521-configurable-auto-upgrade-cron-schedule.md @@ -0,0 +1,44 @@ +# ADR-46521: Configurable Auto-Upgrade Cron Schedule via Polymorphic aw.json Field + +**Date**: 2026-07-19 +**Status**: Draft +**Deciders**: Unknown + +--- + +### Context + +The `agentic-auto-upgrade.yml` workflow runs on a schedule derived by scattering `FUZZY:WEEKLY` using a seed built from the repository slug. This produces a deterministic but repo-specific time that cannot be overridden. Repositories that need a predictable, fixed schedule — for example, to align with release cycles or organizational maintenance windows — have no way to configure the timing without forking or patching the generated workflow. The `aw.json` compiler already uses a polymorphic object/boolean pattern for the `maintenance` field, establishing a precedent for this kind of extension. + +### Decision + +We will extend the `auto_upgrade` field in `aw.json` to accept either a boolean (existing behavior) or an object `{ "cron": "<5-field POSIX expression>" }`. When the object form is used, the `cron` value is passed verbatim to `GenerateAutoUpdateWorkflow` via a new `CustomCron` option and written directly into the generated `agentic-auto-upgrade.yml`, skipping the fuzzy scatter. Omitting `cron` from the object, or using `auto_upgrade: true`, retains the existing scatter behavior. JSON schema validation rejects syntactically invalid cron strings at load time. + +### Alternatives Considered + +#### Alternative 1: Top-level `auto_upgrade_cron` string field + +Add a separate `auto_upgrade_cron` key alongside the boolean `auto_upgrade` in `aw.json`. This is simpler to parse (no polymorphism) and keeps the schema flat. It was explored in an earlier commit on this branch (`feat: add auto_upgrade_cron to aw.json`). It was rejected because it creates a two-field API where both fields must be set together, increasing the chance of misuse (e.g., setting `auto_upgrade_cron` while `auto_upgrade` is `false` or omitted). Nesting `cron` inside `auto_upgrade` makes the relationship explicit: the object form implies enabled. + +#### Alternative 2: Separate top-level `auto_upgrade_schedule` object + +Introduce a new top-level `auto_upgrade_schedule: { cron: "..." }` key independent of the boolean `auto_upgrade`. This keeps backward compatibility without polymorphism but adds a third distinct configuration surface for a single feature, fragmenting the API further. It was not chosen because it diverges from the `maintenance` field pattern and forces users to set two keys to configure one behavior. + +### Consequences + +#### Positive +- Repositories can pin the auto-upgrade workflow to a predictable, fixed cron schedule without editing the generated file. +- Invalid cron expressions are caught at `LoadRepoConfig` time via JSON schema validation, providing early feedback before any workflow is generated. +- The object form follows the existing `maintenance` polymorphism pattern already established in `aw.json`, keeping the API internally consistent. + +#### Negative +- The `auto_upgrade` field is now polymorphic (boolean | object), requiring a custom `UnmarshalJSON` implementation with try-boolean-first, fall-back-to-object logic. This adds parse complexity and a subtle order dependency. +- The object form with no `cron` key (`{ }`) and the boolean `true` are semantically identical — two ways to enable the default scatter schedule, which may confuse users reading existing configs. + +#### Neutral +- The `RepoConfig` struct gains a new `AutoUpgradeCron string` field that is always empty when the boolean form is used, so callers that don't need custom cron need not change. +- All three `GenerateAutoUpdateWorkflow` call sites in `maintenance_workflow.go` are updated uniformly via a new `autoUpgradeCronFrom(cfg)` helper, keeping the change mechanical and auditable. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/linters/README.md b/pkg/linters/README.md index a2dc507b127..ccdd53b1080 100644 --- a/pkg/linters/README.md +++ b/pkg/linters/README.md @@ -236,6 +236,7 @@ _ = timesleepnocontext.Analyzer - `github.com/github/gh-aw/pkg/linters/timeafterleak` — time-after-leak analyzer subpackage - `github.com/github/gh-aw/pkg/linters/timesleepnocontext` — time-sleep-no-context analyzer subpackage - `github.com/github/gh-aw/pkg/linters/tolowerequalfold` — to-lower-equal-fold analyzer subpackage +- `github.com/github/gh-aw/pkg/linters/trimleftright` — trim-left-right analyzer subpackage - `github.com/github/gh-aw/pkg/linters/uncheckedtypeassertion` — unchecked-type-assertion analyzer subpackage - `github.com/github/gh-aw/pkg/linters/wgdonenotdeferred` — wg-done-not-deferred analyzer subpackage - `github.com/github/gh-aw/pkg/linters/writebytestring` — write-byte-string analyzer subpackage diff --git a/pkg/parser/schemas/repo_config_schema.json b/pkg/parser/schemas/repo_config_schema.json index 26c576b11c9..48164fef379 100644 --- a/pkg/parser/schemas/repo_config_schema.json +++ b/pkg/parser/schemas/repo_config_schema.json @@ -35,8 +35,27 @@ "examples": ["+00:00", "-08:00", "+05:30"] }, "auto_upgrade": { - "description": "When true, the compiler generates agentic-auto-upgrade.yml that runs on a fuzzy weekly schedule and inlines the 'upgrade' operation to check for and report available workflow upgrades via a GitHub issue. Defaults to false (opt-in).", - "type": "boolean" + "description": "Controls agentic-auto-upgrade.yml generation. Set to true to enable with the default fuzzy weekly schedule, false to disable, or an object to configure a custom schedule. Defaults to false (opt-in).", + "oneOf": [ + { + "type": "boolean", + "description": "Set to true to enable agentic-auto-upgrade.yml with the default fuzzy weekly schedule, or false to disable." + }, + { + "type": "object", + "description": "Enable auto-upgrade with a custom schedule. Providing an object implies enabled.", + "additionalProperties": false, + "properties": { + "cron": { + "description": "Custom cron expression for the agentic-auto-upgrade workflow schedule. When set, overrides the default fuzzy weekly schedule. Must be a valid 5-field POSIX cron expression (e.g. '0 9 * * 1' for Monday at 09:00 UTC).", + "type": "string", + "minLength": 9, + "pattern": "^[0-9*/,\\-]+ [0-9*/,\\-]+ [0-9*/,\\-]+ [0-9*/,\\-]+ [0-9*/,\\-]+$", + "examples": ["0 9 * * 1", "30 5 * * 1-5", "0 0 * * 0"] + } + } + } + ] }, "maintenance": { "description": "Configuration for the agentic-maintenance workflow. Set to false to disable maintenance entirely, or provide an object to configure it.", diff --git a/pkg/workflow/auto_update_workflow.go b/pkg/workflow/auto_update_workflow.go index 77c340137c5..6aeaa71ec71 100644 --- a/pkg/workflow/auto_update_workflow.go +++ b/pkg/workflow/auto_update_workflow.go @@ -52,6 +52,10 @@ type GenerateAutoUpdateWorkflowOptions struct { ActionTag string // Resolver optionally resolves setup-cli action tags to SHA-pinned refs. Resolver SHAResolver + // CustomCron is an optional cron expression that overrides the default + // fuzzy weekly schedule. When non-empty, it is used as-is in the generated + // workflow without scattering. An empty string falls back to FUZZY:WEEKLY. + CustomCron string } // GenerateAutoUpdateWorkflow generates or removes the agentic-auto-upgrade.yml workflow @@ -83,12 +87,19 @@ func GenerateAutoUpdateWorkflow(opts GenerateAutoUpdateWorkflowOptions) error { actionMode = DetectActionMode(opts.Version) } - seed := buildAutoUpdateSeed(opts.RepoSlug, actionMode) - cronSchedule, err := parser.ScatterSchedule("FUZZY:WEEKLY", seed) - if err != nil { - return fmt.Errorf("failed to scatter FUZZY:WEEKLY schedule for auto-update workflow: %w", err) + var cronSchedule string + if opts.CustomCron != "" { + cronSchedule = opts.CustomCron + autoUpdateWorkflowLog.Printf("Using custom cron schedule: %q", cronSchedule) + } else { + seed := buildAutoUpdateSeed(opts.RepoSlug, actionMode) + var err error + cronSchedule, err = parser.ScatterSchedule("FUZZY:WEEKLY", seed) + if err != nil { + return fmt.Errorf("failed to scatter FUZZY:WEEKLY schedule for auto-update workflow: %w", err) + } + autoUpdateWorkflowLog.Printf("Scattered FUZZY:WEEKLY to %q for seed %q", cronSchedule, seed) } - autoUpdateWorkflowLog.Printf("Scattered FUZZY:WEEKLY to %q for seed %q", cronSchedule, seed) setupActionRef := opts.SetupActionRef if setupActionRef == "" { @@ -109,6 +120,7 @@ func GenerateAutoUpdateWorkflow(opts GenerateAutoUpdateWorkflowOptions) error { githubScriptPin, generateInstallCLISteps(ctx, actionMode, opts.Version, opts.ActionTag, opts.Resolver), getCLICmdPrefix(actionMode), + opts.CustomCron != "", ) autoUpdateWorkflowLog.Printf("Writing auto-update workflow to %s", outputFile) @@ -151,15 +163,34 @@ func buildAutoUpdateSeed(repoSlug string, actionMode ActionMode) string { // buildAutoUpdateWorkflowYAML generates the YAML content for agentic-auto-upgrade.yml. func buildAutoUpdateWorkflowYAML( cronSchedule, setupActionRef, githubScriptPin, installCLISteps, cliCmdPrefix string, + isCustomCron bool, ) string { - customInstructions := `Alternative regeneration methods: + var customInstructions string + if isCustomCron { + customInstructions = `Alternative regeneration methods: make recompile Or use the gh-aw CLI directly: ./gh-aw compile --validate --verbose -The workflow is generated when auto_upgrade is set to true in aw.json. +The workflow is generated when auto_upgrade.cron is set in aw.json. +The schedule is pinned to the custom cron expression configured in aw.json.` + } else { + customInstructions = `Alternative regeneration methods: + make recompile + +Or use the gh-aw CLI directly: + ./gh-aw compile --validate --verbose + +The workflow is generated when auto_upgrade is enabled in aw.json (true or object form). +When auto_upgrade is an object without a cron, the fuzzy weekly schedule is used. The weekly schedule is deterministically scattered based on the repository slug.` + } + + scheduleComment := "Custom schedule (auto-upgrade)" + if !isCustomCron { + scheduleComment = "Weekly (auto-upgrade)" + } header := GenerateWorkflowHeader("", "pkg/workflow/auto_update_workflow.go", customInstructions) @@ -167,7 +198,7 @@ The weekly schedule is deterministically scattered based on the repository slug. on: schedule: - - cron: "` + cronSchedule + `" # Weekly (auto-upgrade) + - cron: "` + cronSchedule + `" # ` + scheduleComment + ` workflow_dispatch: permissions: diff --git a/pkg/workflow/auto_update_workflow_test.go b/pkg/workflow/auto_update_workflow_test.go index 2773ca75fb1..46fa0ec802c 100644 --- a/pkg/workflow/auto_update_workflow_test.go +++ b/pkg/workflow/auto_update_workflow_test.go @@ -247,3 +247,46 @@ func extractCronLine(content string) string { } return "" } + +func TestGenerateAutoUpdateWorkflow_CustomCron(t *testing.T) { + dir := t.TempDir() + const customCron = "0 9 * * 1" + + err := GenerateAutoUpdateWorkflow(GenerateAutoUpdateWorkflowOptions{ + WorkflowDir: dir, + Enabled: true, + RepoSlug: "owner/repo", + CustomCron: customCron, + }) + require.NoError(t, err, "GenerateAutoUpdateWorkflow should succeed with custom cron") + + content, err := os.ReadFile(filepath.Join(dir, AutoUpdateWorkflowFileName)) + require.NoError(t, err) + + assert.Contains(t, string(content), `cron: "0 9 * * 1"`, "should use the custom cron expression verbatim") + assert.Contains(t, string(content), "Custom schedule (auto-upgrade)", "should use custom schedule comment for custom cron") + assert.NotContains(t, string(content), "Weekly (auto-upgrade)", "should not use weekly comment for custom cron") + assert.Contains(t, string(content), "auto_upgrade.cron is set in aw.json", "header should describe custom cron configuration") +} + +func TestGenerateAutoUpdateWorkflow_CustomCronOverridesFuzzy(t *testing.T) { + dir := t.TempDir() + const customCron = "30 5 * * 1-5" + + err := GenerateAutoUpdateWorkflow(GenerateAutoUpdateWorkflowOptions{ + WorkflowDir: dir, + Enabled: true, + RepoSlug: "org/repo", + ActionMode: ActionModeAction, + CustomCron: customCron, + }) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(dir, AutoUpdateWorkflowFileName)) + require.NoError(t, err) + + cronLine := extractCronLine(string(content)) + assert.Contains(t, cronLine, customCron, "custom cron should override the per-repo fuzzy weekly schedule") + assert.Contains(t, string(content), "Custom schedule (auto-upgrade)", "should use custom schedule comment") + assert.NotContains(t, string(content), "Weekly (auto-upgrade)", "should not use weekly comment when custom cron is set") +} diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index 8182081f3ee..9666dccf776 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -173,6 +173,7 @@ func GenerateMaintenanceWorkflow(ctx context.Context, opts GenerateMaintenanceWo Version: version, ActionTag: actionTag, Resolver: resolver, + CustomCron: autoUpgradeCronFrom(repoConfig), }) } @@ -237,6 +238,7 @@ func GenerateMaintenanceWorkflow(ctx context.Context, opts GenerateMaintenanceWo Version: version, ActionTag: actionTag, Resolver: resolver, + CustomCron: autoUpgradeCronFrom(repoConfig), }) } @@ -321,9 +323,19 @@ func GenerateMaintenanceWorkflow(ctx context.Context, opts GenerateMaintenanceWo Version: version, ActionTag: actionTag, Resolver: resolver, + CustomCron: autoUpgradeCronFrom(repoConfig), }) } +// autoUpgradeCronFrom returns the custom cron expression from the repo config, +// or an empty string if the config is nil or no custom cron is set. +func autoUpgradeCronFrom(cfg *RepoConfig) string { + if cfg == nil { + return "" + } + return cfg.AutoUpgradeCron +} + // handleMaintenanceDisabled handles the case where maintenance is disabled in repo config. // It warns about workflows that use expires and deletes any existing maintenance workflow. func handleMaintenanceDisabled(workflowDataList []*WorkflowData, workflowDir string) error { diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index 03eebb84276..2c221d6c294 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -11,6 +11,7 @@ // "help_command": false, // disables builtin centralized /help comment handler // "utc": "-08:00", // project home UTC offset for rendered local times // "auto_upgrade": true, // set to true to generate agentic-auto-upgrade.yml with weekly schedule +// "auto_upgrade": { "cron": "0 9 * * 1" }, // or object form: enable with custom cron (Monday 09:00 UTC) // "maintenance": { // enables generation of agentics-maintenance.yml // "runs_on": "custom runner", // string or string[] – runner label(s) for all // "action_failure_issue_expires": 72, // expiration (hours) for conclusion failure issues @@ -34,6 +35,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "github.com/github/gh-aw/pkg/logger" @@ -166,6 +168,11 @@ type RepoConfig struct { // Opt-in: nil (omitted) or false both disable generation. AutoUpgrade *bool + // AutoUpgradeCron is an optional custom cron expression for the + // agentic-auto-upgrade workflow schedule. When non-empty, it overrides + // the default fuzzy weekly schedule. Requires AutoUpgrade to be true. + AutoUpgradeCron string + // MaintenanceDisabled is true when maintenance has been explicitly set to false // in aw.json, disabling agentic-maintenance generation and any features that // depend on it (such as expires). @@ -193,14 +200,17 @@ func (r *RepoConfig) IsAutoUpgradeEnabled() bool { } // UnmarshalJSON implements json.Unmarshaler to handle the polymorphic maintenance -// field, which can be either the boolean false (disable) or a configuration object. +// and auto_upgrade fields. maintenance can be either the boolean false (disable) +// or a configuration object; auto_upgrade can be a boolean or an object with an +// optional cron field. func (r *RepoConfig) UnmarshalJSON(data []byte) error { - // Use an intermediate struct with json.RawMessage to defer maintenance parsing. + // Use an intermediate struct with json.RawMessage to defer maintenance and + // auto_upgrade parsing. var raw struct { GHES bool `json:"ghes,omitempty"` HelpCommand *bool `json:"help_command,omitempty"` // nil = use default (enabled) UTC string `json:"utc,omitempty"` - AutoUpgrade *bool `json:"auto_upgrade,omitempty"` + AutoUpgrade json.RawMessage `json:"auto_upgrade,omitempty"` Maintenance json.RawMessage `json:"maintenance,omitempty"` ActionPins map[string]string `json:"action_pins,omitempty"` } @@ -211,9 +221,27 @@ func (r *RepoConfig) UnmarshalJSON(data []byte) error { r.GHES = raw.GHES r.HelpCommand = raw.HelpCommand r.UTC = strings.TrimSpace(raw.UTC) - r.AutoUpgrade = raw.AutoUpgrade r.ActionPins = raw.ActionPins + // Parse polymorphic auto_upgrade: boolean or { "cron": "..." } object. + if len(raw.AutoUpgrade) > 0 && string(raw.AutoUpgrade) != "null" { + var b bool + if err := json.Unmarshal(raw.AutoUpgrade, &b); err == nil { + r.AutoUpgrade = &b + } else { + // Object form: { "cron": "..." } — implies enabled. + var autoUpgradeObj struct { + Cron string `json:"cron,omitempty"` + } + if err := json.Unmarshal(raw.AutoUpgrade, &autoUpgradeObj); err != nil { + return fmt.Errorf("invalid auto_upgrade configuration: %w", err) + } + enabled := true + r.AutoUpgrade = &enabled + r.AutoUpgradeCron = strings.TrimSpace(autoUpgradeObj.Cron) + } + } + if len(raw.Maintenance) == 0 || string(raw.Maintenance) == "null" { return nil } @@ -313,6 +341,11 @@ func validateRepoConfigValues(cfg *RepoConfig) error { } cfg.UTC = normalized } + if cfg.AutoUpgradeCron != "" { + if err := validateCronExpression(cfg.AutoUpgradeCron); err != nil { + return fmt.Errorf("invalid %s: auto_upgrade.cron %w", RepoConfigFileName, err) + } + } if cfg.Maintenance != nil { seenDisabledJobs := map[string]string{} for _, jobName := range cfg.Maintenance.DisabledJobs { @@ -380,3 +413,86 @@ func (r *RepoConfig) ActionFailureIssueExpiresHours() int { } return DefaultActionFailureIssueExpiresHours } + +// cronFieldRange describes the allowed numeric range for a cron field. +type cronFieldRange struct { + name string + min int + max int +} + +var cronFieldRanges = []cronFieldRange{ + {"minute", 0, 59}, + {"hour", 0, 23}, + {"day-of-month", 1, 31}, + {"month", 1, 12}, + {"day-of-week", 0, 7}, +} + +// validateCronExpression validates a 5-field POSIX cron expression. +// It checks that the expression has exactly 5 space-separated fields and that +// each field's numeric literals fall within the allowed range for that position. +func validateCronExpression(expr string) error { + fields := strings.Split(expr, " ") + if len(fields) != 5 { + return fmt.Errorf("must have exactly 5 fields (got %d)", len(fields)) + } + for i, field := range fields { + r := cronFieldRanges[i] + if err := validateCronField(field, r.min, r.max); err != nil { + return fmt.Errorf("field %d (%s): %w", i+1, r.name, err) + } + } + return nil +} + +// validateCronField validates a single cron field value against an allowed range. +// It supports the following forms: *, n, n-m, n/s, */s, n-m/s, and comma-separated combinations. +func validateCronField(field string, min, max int) error { + for part := range strings.SplitSeq(field, ",") { + if err := validateCronPart(part, min, max); err != nil { + return err + } + } + return nil +} + +// validateCronPart validates a single part of a cron field (before splitting on comma). +func validateCronPart(part string, min, max int) error { + // Strip optional step value (e.g. "*/5" or "1-5/2"). + base, step, hasStep := strings.Cut(part, "/") + if hasStep { + sv, err := strconv.Atoi(step) + if err != nil || sv < 1 { + return fmt.Errorf("invalid step value %q (must be a positive integer)", step) + } + } + part = base + + if part == "*" { + return nil + } + + // Range (e.g. "1-5"). + if lo, hi, ok := strings.Cut(part, "-"); ok { + loN, err1 := strconv.Atoi(lo) + hiN, err2 := strconv.Atoi(hi) + if err1 != nil || err2 != nil { + return fmt.Errorf("invalid range %q", part) + } + if loN < min || hiN > max || loN > hiN { + return fmt.Errorf("range %d-%d out of bounds [%d-%d]", loN, hiN, min, max) + } + return nil + } + + // Plain integer. + n, err := strconv.Atoi(part) + if err != nil { + return fmt.Errorf("invalid value %q", part) + } + if n < min || n > max { + return fmt.Errorf("value %d out of bounds [%d-%d]", n, min, max) + } + return nil +} diff --git a/pkg/workflow/repo_config_test.go b/pkg/workflow/repo_config_test.go index 3d308e0e9e2..9398890f4ce 100644 --- a/pkg/workflow/repo_config_test.go +++ b/pkg/workflow/repo_config_test.go @@ -365,6 +365,65 @@ func TestIsAutoUpgradeEnabled_NilConfig(t *testing.T) { assert.False(t, r.IsAutoUpgradeEnabled(), "IsAutoUpgradeEnabled should return false for nil RepoConfig") } +func TestLoadRepoConfig_AutoUpgradeCron(t *testing.T) { + t.Run("object form with cron enables auto_upgrade", func(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"auto_upgrade": {"cron": "0 9 * * 1"}}`) + + cfg, err := LoadRepoConfig(dir) + require.NoError(t, err, "valid aw.json with auto_upgrade object should load without error") + require.NotNil(t, cfg.AutoUpgrade, "auto_upgrade should be set") + assert.True(t, *cfg.AutoUpgrade, "auto_upgrade object form should imply enabled") + assert.True(t, cfg.IsAutoUpgradeEnabled(), "IsAutoUpgradeEnabled should return true for object form") + assert.Equal(t, "0 9 * * 1", cfg.AutoUpgradeCron, "cron should be set from nested field") + }) + + t.Run("object form without cron uses fuzzy schedule", func(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"auto_upgrade": {}}`) + + cfg, err := LoadRepoConfig(dir) + require.NoError(t, err) + require.NotNil(t, cfg.AutoUpgrade, "auto_upgrade should be set") + assert.True(t, *cfg.AutoUpgrade, "empty object should imply enabled") + assert.Empty(t, cfg.AutoUpgradeCron, "AutoUpgradeCron should be empty when cron is omitted") + }) + + t.Run("boolean true has no cron", func(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"auto_upgrade": true}`) + + cfg, err := LoadRepoConfig(dir) + require.NoError(t, err) + assert.Empty(t, cfg.AutoUpgradeCron, "AutoUpgradeCron should be empty when using boolean form") + }) + + t.Run("rejects invalid cron pattern", func(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"auto_upgrade": {"cron": "not-a-cron"}}`) + + // Invalid cron is rejected by JSON schema validation in LoadRepoConfig. + _, err := LoadRepoConfig(dir) + assert.Error(t, err, "invalid cron in auto_upgrade object should return an error") + }) + + t.Run("rejects out-of-range cron values", func(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"auto_upgrade": {"cron": "99 99 99 99 99"}}`) + + _, err := LoadRepoConfig(dir) + assert.Error(t, err, "out-of-range cron values should return an error") + }) + + t.Run("rejects six-field cron", func(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"auto_upgrade": {"cron": "0 0 * * * *"}}`) + + _, err := LoadRepoConfig(dir) + assert.Error(t, err, "six-field cron should return an error") + }) +} + func TestLoadRepoConfig_ActionPins(t *testing.T) { t.Run("loads action_pins mapping", func(t *testing.T) { dir := t.TempDir() @@ -416,6 +475,42 @@ func TestLoadRepoConfig_ActionPins(t *testing.T) { }) } +func TestValidateCronExpression(t *testing.T) { + valid := []string{ + "0 9 * * 1", + "30 5 * * 1-5", + "0 0 * * 0", + "*/15 * * * *", + "0 0 1,15 * *", + "59 23 31 12 7", + "0 0 * * 1-5/2", + } + for _, expr := range valid { + t.Run("valid: "+expr, func(t *testing.T) { + assert.NoError(t, validateCronExpression(expr), "should accept %q", expr) + }) + } + + invalid := []string{ + "not-a-cron", + "99 99 99 99 99", + "0 0 * * * *", // 6 fields + "0 0 * *", // 4 fields + "60 0 * * *", // minute out of range + "0 24 * * *", // hour out of range + "0 0 0 * *", // DOM 0 out of range + "0 0 * 0 *", // month 0 out of range + "0 0 * 13 *", // month 13 out of range + "0 0 * * 8", // DOW 8 out of range + "0 0 * * 1/0", // step 0 invalid + } + for _, expr := range invalid { + t.Run("invalid: "+expr, func(t *testing.T) { + assert.Error(t, validateCronExpression(expr), "should reject %q", expr) + }) + } +} + // writeAWJSON creates .github/workflows/aw.json with the given JSON content. func writeAWJSON(t *testing.T, gitRoot, content string) { t.Helper()