From 0e5e8bbb37ebafb8fbdbe788d804d5f5c6f7ef62 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Jul 2026 04:12:30 +0000 Subject: [PATCH 1/6] feat: add auto_upgrade_cron to aw.json for configurable auto-upgrade schedule - Add auto_upgrade_cron field to repo_config_schema.json with cron pattern validation - Add AutoUpgradeCron field to RepoConfig struct, wire through GenerateAutoUpdateWorkflow - Add CustomCron option to GenerateAutoUpdateWorkflowOptions; used verbatim when set - Add autoUpgradeCronFrom helper and pass custom cron at all three call sites in maintenance_workflow.go - Set auto_upgrade_cron: 0 9 * * 1 in .github/workflows/aw.json (Monday 09:00 UTC) - Regenerate agentic-auto-upgrade.yml with the explicit cron schedule - Add unit tests for custom cron override and aw.json parsing Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentic-auto-upgrade.yml | 2 +- .github/workflows/aw.json | 1 + pkg/parser/schemas/repo_config_schema.json | 7 ++++ pkg/workflow/auto_update_workflow.go | 21 +++++++++--- pkg/workflow/auto_update_workflow_test.go | 38 ++++++++++++++++++++++ pkg/workflow/maintenance_workflow.go | 12 +++++++ pkg/workflow/repo_config.go | 20 ++++++++---- pkg/workflow/repo_config_test.go | 28 ++++++++++++++++ 8 files changed, 117 insertions(+), 12 deletions(-) diff --git a/.github/workflows/agentic-auto-upgrade.yml b/.github/workflows/agentic-auto-upgrade.yml index 6aff9d33647..4ab76a35a10 100644 --- a/.github/workflows/agentic-auto-upgrade.yml +++ b/.github/workflows/agentic-auto-upgrade.yml @@ -34,7 +34,7 @@ name: Agentic Auto-Upgrade on: schedule: - - cron: "41 23 * * 1" # Weekly (auto-upgrade) + - cron: "0 9 * * 1" # Weekly (auto-upgrade) workflow_dispatch: permissions: diff --git a/.github/workflows/aw.json b/.github/workflows/aw.json index eb2cac13e0a..47ee1eb1867 100644 --- a/.github/workflows/aw.json +++ b/.github/workflows/aw.json @@ -1,5 +1,6 @@ { "auto_upgrade": true, + "auto_upgrade_cron": "0 9 * * 1", "ghes": true, "maintenance": { "action_failure_issue_expires": 12, diff --git a/pkg/parser/schemas/repo_config_schema.json b/pkg/parser/schemas/repo_config_schema.json index 26c576b11c9..f2f63fb6e8b 100644 --- a/pkg/parser/schemas/repo_config_schema.json +++ b/pkg/parser/schemas/repo_config_schema.json @@ -38,6 +38,13 @@ "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" }, + "auto_upgrade_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). Requires auto_upgrade: true.", + "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.", "oneOf": [ diff --git a/pkg/workflow/auto_update_workflow.go b/pkg/workflow/auto_update_workflow.go index 77c340137c5..0db0a943230 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 == "" { diff --git a/pkg/workflow/auto_update_workflow_test.go b/pkg/workflow/auto_update_workflow_test.go index 2773ca75fb1..0c95604bf73 100644 --- a/pkg/workflow/auto_update_workflow_test.go +++ b/pkg/workflow/auto_update_workflow_test.go @@ -247,3 +247,41 @@ 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") +} + +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") +} diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index 8182081f3ee..0ef9e9a32bb 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: repoConfig.AutoUpgradeCron, }) } @@ -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..b65a306c6c9 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", // optional: override the default fuzzy weekly schedule // "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 @@ -166,6 +167,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). @@ -197,12 +203,13 @@ func (r *RepoConfig) IsAutoUpgradeEnabled() bool { func (r *RepoConfig) UnmarshalJSON(data []byte) error { // Use an intermediate struct with json.RawMessage to defer maintenance 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"` - Maintenance json.RawMessage `json:"maintenance,omitempty"` - ActionPins map[string]string `json:"action_pins,omitempty"` + 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"` + AutoUpgradeCron string `json:"auto_upgrade_cron,omitempty"` + Maintenance json.RawMessage `json:"maintenance,omitempty"` + ActionPins map[string]string `json:"action_pins,omitempty"` } if err := json.Unmarshal(data, &raw); err != nil { return err @@ -212,6 +219,7 @@ func (r *RepoConfig) UnmarshalJSON(data []byte) error { r.HelpCommand = raw.HelpCommand r.UTC = strings.TrimSpace(raw.UTC) r.AutoUpgrade = raw.AutoUpgrade + r.AutoUpgradeCron = strings.TrimSpace(raw.AutoUpgradeCron) r.ActionPins = raw.ActionPins if len(raw.Maintenance) == 0 || string(raw.Maintenance) == "null" { diff --git a/pkg/workflow/repo_config_test.go b/pkg/workflow/repo_config_test.go index 3d308e0e9e2..d94239d809a 100644 --- a/pkg/workflow/repo_config_test.go +++ b/pkg/workflow/repo_config_test.go @@ -365,6 +365,34 @@ 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("loads auto_upgrade_cron", func(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"auto_upgrade": true, "auto_upgrade_cron": "0 9 * * 1"}`) + + cfg, err := LoadRepoConfig(dir) + require.NoError(t, err, "valid aw.json with auto_upgrade_cron should load without error") + assert.Equal(t, "0 9 * * 1", cfg.AutoUpgradeCron, "auto_upgrade_cron should be set") + }) + + t.Run("auto_upgrade_cron omitted defaults to empty string", 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 omitted") + }) + + t.Run("rejects invalid cron pattern", func(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"auto_upgrade": true, "auto_upgrade_cron": "not-a-cron"}`) + + _, err := LoadRepoConfig(dir) + assert.Error(t, err, "invalid auto_upgrade_cron should return an error") + }) +} + func TestLoadRepoConfig_ActionPins(t *testing.T) { t.Run("loads action_pins mapping", func(t *testing.T) { dir := t.TempDir() From 95247c23702dfa46160d94ed7d08d562d8d55d5c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Jul 2026 04:18:07 +0000 Subject: [PATCH 2/6] fix: use autoUpgradeCronFrom helper consistently at all three call sites Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/maintenance_workflow.go | 2 +- pkg/workflow/repo_config_test.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index 0ef9e9a32bb..9666dccf776 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -173,7 +173,7 @@ func GenerateMaintenanceWorkflow(ctx context.Context, opts GenerateMaintenanceWo Version: version, ActionTag: actionTag, Resolver: resolver, - CustomCron: repoConfig.AutoUpgradeCron, + CustomCron: autoUpgradeCronFrom(repoConfig), }) } diff --git a/pkg/workflow/repo_config_test.go b/pkg/workflow/repo_config_test.go index d94239d809a..9a1dd03ab47 100644 --- a/pkg/workflow/repo_config_test.go +++ b/pkg/workflow/repo_config_test.go @@ -388,6 +388,7 @@ func TestLoadRepoConfig_AutoUpgradeCron(t *testing.T) { dir := t.TempDir() writeAWJSON(t, dir, `{"auto_upgrade": true, "auto_upgrade_cron": "not-a-cron"}`) + // Invalid cron is rejected by JSON schema validation in LoadRepoConfig. _, err := LoadRepoConfig(dir) assert.Error(t, err, "invalid auto_upgrade_cron should return an error") }) From 851b6329c5657da295ea0d6b90b93c6478be8088 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:11:18 +0000 Subject: [PATCH 3/6] feat: restructure auto_upgrade_cron as nested cron field in auto_upgrade object Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/aw.json | 3 +- pkg/parser/schemas/repo_config_schema.json | 30 ++++++++++----- pkg/workflow/repo_config.go | 43 ++++++++++++++++------ pkg/workflow/repo_config_test.go | 30 +++++++++++---- 4 files changed, 75 insertions(+), 31 deletions(-) diff --git a/.github/workflows/aw.json b/.github/workflows/aw.json index 47ee1eb1867..b9f4da9915f 100644 --- a/.github/workflows/aw.json +++ b/.github/workflows/aw.json @@ -1,6 +1,5 @@ { - "auto_upgrade": true, - "auto_upgrade_cron": "0 9 * * 1", + "auto_upgrade": { "cron": "0 9 * * 1" }, "ghes": true, "maintenance": { "action_failure_issue_expires": 12, diff --git a/pkg/parser/schemas/repo_config_schema.json b/pkg/parser/schemas/repo_config_schema.json index f2f63fb6e8b..aa7439779aa 100644 --- a/pkg/parser/schemas/repo_config_schema.json +++ b/pkg/parser/schemas/repo_config_schema.json @@ -35,15 +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" - }, - "auto_upgrade_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). Requires auto_upgrade: true.", - "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"] + "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/repo_config.go b/pkg/workflow/repo_config.go index b65a306c6c9..44b1289aaa0 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -11,7 +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", // optional: override the default fuzzy 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 @@ -199,17 +199,19 @@ 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"` - AutoUpgradeCron string `json:"auto_upgrade_cron,omitempty"` - Maintenance json.RawMessage `json:"maintenance,omitempty"` - ActionPins map[string]string `json:"action_pins,omitempty"` + GHES bool `json:"ghes,omitempty"` + HelpCommand *bool `json:"help_command,omitempty"` // nil = use default (enabled) + UTC string `json:"utc,omitempty"` + AutoUpgrade json.RawMessage `json:"auto_upgrade,omitempty"` + Maintenance json.RawMessage `json:"maintenance,omitempty"` + ActionPins map[string]string `json:"action_pins,omitempty"` } if err := json.Unmarshal(data, &raw); err != nil { return err @@ -218,10 +220,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.AutoUpgradeCron = strings.TrimSpace(raw.AutoUpgradeCron) 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 } diff --git a/pkg/workflow/repo_config_test.go b/pkg/workflow/repo_config_test.go index 9a1dd03ab47..926df450fb4 100644 --- a/pkg/workflow/repo_config_test.go +++ b/pkg/workflow/repo_config_test.go @@ -366,31 +366,45 @@ func TestIsAutoUpgradeEnabled_NilConfig(t *testing.T) { } func TestLoadRepoConfig_AutoUpgradeCron(t *testing.T) { - t.Run("loads auto_upgrade_cron", func(t *testing.T) { + t.Run("object form with cron enables auto_upgrade", func(t *testing.T) { dir := t.TempDir() - writeAWJSON(t, dir, `{"auto_upgrade": true, "auto_upgrade_cron": "0 9 * * 1"}`) + writeAWJSON(t, dir, `{"auto_upgrade": {"cron": "0 9 * * 1"}}`) cfg, err := LoadRepoConfig(dir) - require.NoError(t, err, "valid aw.json with auto_upgrade_cron should load without error") - assert.Equal(t, "0 9 * * 1", cfg.AutoUpgradeCron, "auto_upgrade_cron should be set") + 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("auto_upgrade_cron omitted defaults to empty string", func(t *testing.T) { + 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 omitted") + 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": true, "auto_upgrade_cron": "not-a-cron"}`) + 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 auto_upgrade_cron should return an error") + assert.Error(t, err, "invalid cron in auto_upgrade object should return an error") }) } From 87317c851b2fd9772d51da55dc3ec2113ea50bf3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:30:57 +0000 Subject: [PATCH 4/6] docs(adr): add draft ADR-46521 for configurable auto-upgrade cron schedule --- ...configurable-auto-upgrade-cron-schedule.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/adr/46521-configurable-auto-upgrade-cron-schedule.md 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.* From cdd5accd4d3cb5872a1e5e73d424c38085f485c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:10:33 +0000 Subject: [PATCH 5/6] fix: tighten cron validation and fix generated workflow comments for custom schedules - Schema: fix cron pattern to reject spaces inside fields (prevents 6-field values like "0 0 * * * *" from matching) - Go: add validateCronExpression with per-field range checks (rejects "99 99 99 99 99") - auto_update_workflow.go: make header and schedule comment conditional on custom vs fuzzy cron - Regenerate agentic-auto-upgrade.yml with correct custom schedule comments - Tests: add coverage for out-of-range and 6-field rejections; assert correct comment text in custom cron tests Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentic-auto-upgrade.yml | 6 +- pkg/parser/schemas/repo_config_schema.json | 2 +- pkg/workflow/auto_update_workflow.go | 23 +++++- pkg/workflow/auto_update_workflow_test.go | 5 ++ pkg/workflow/repo_config.go | 89 ++++++++++++++++++++++ pkg/workflow/repo_config_test.go | 52 +++++++++++++ 6 files changed, 171 insertions(+), 6 deletions(-) diff --git a/.github/workflows/agentic-auto-upgrade.yml b/.github/workflows/agentic-auto-upgrade.yml index 4ab76a35a10..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: "0 9 * * 1" # Weekly (auto-upgrade) + - cron: "0 9 * * 1" # Custom schedule (auto-upgrade) workflow_dispatch: permissions: diff --git a/pkg/parser/schemas/repo_config_schema.json b/pkg/parser/schemas/repo_config_schema.json index aa7439779aa..48164fef379 100644 --- a/pkg/parser/schemas/repo_config_schema.json +++ b/pkg/parser/schemas/repo_config_schema.json @@ -50,7 +50,7 @@ "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*/,\\- ]+$", + "pattern": "^[0-9*/,\\-]+ [0-9*/,\\-]+ [0-9*/,\\-]+ [0-9*/,\\-]+ [0-9*/,\\-]+$", "examples": ["0 9 * * 1", "30 5 * * 1-5", "0 0 * * 0"] } } diff --git a/pkg/workflow/auto_update_workflow.go b/pkg/workflow/auto_update_workflow.go index 0db0a943230..75874c36601 100644 --- a/pkg/workflow/auto_update_workflow.go +++ b/pkg/workflow/auto_update_workflow.go @@ -120,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) @@ -162,8 +163,20 @@ 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.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: @@ -171,6 +184,12 @@ Or use the gh-aw CLI directly: 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.` + } + + scheduleComment := "Custom schedule (auto-upgrade)" + if !isCustomCron { + scheduleComment = "Weekly (auto-upgrade)" + } header := GenerateWorkflowHeader("", "pkg/workflow/auto_update_workflow.go", customInstructions) @@ -178,7 +197,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 0c95604bf73..46fa0ec802c 100644 --- a/pkg/workflow/auto_update_workflow_test.go +++ b/pkg/workflow/auto_update_workflow_test.go @@ -264,6 +264,9 @@ func TestGenerateAutoUpdateWorkflow_CustomCron(t *testing.T) { 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) { @@ -284,4 +287,6 @@ func TestGenerateAutoUpdateWorkflow_CustomCronOverridesFuzzy(t *testing.T) { 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/repo_config.go b/pkg/workflow/repo_config.go index 44b1289aaa0..2c221d6c294 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -35,6 +35,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "github.com/github/gh-aw/pkg/logger" @@ -340,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 { @@ -407,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 926df450fb4..9398890f4ce 100644 --- a/pkg/workflow/repo_config_test.go +++ b/pkg/workflow/repo_config_test.go @@ -406,6 +406,22 @@ func TestLoadRepoConfig_AutoUpgradeCron(t *testing.T) { _, 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) { @@ -459,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() From 72ff59c4b0c8ee1f0bf947a851da0440bfa2c6bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:42:56 +0000 Subject: [PATCH 6/6] fix: add trimleftright to linters README and improve fuzzy-cron regeneration comment Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/linters/README.md | 3 +++ pkg/workflow/auto_update_workflow.go | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/linters/README.md b/pkg/linters/README.md index 2af2f13dc91..484545f3f84 100644 --- a/pkg/linters/README.md +++ b/pkg/linters/README.md @@ -55,6 +55,7 @@ This package currently provides custom Go analyzers in the following subpackages - `timeafterleak` — reports `time.After` calls used as the channel-receive expression in a `select` case inside a `for` or `range` loop that leak a timer channel on each iteration when another case fires first. - `timesleepnocontext` — reports `time.Sleep` calls inside functions that already receive a `context.Context`, where a context-aware `select` should be used instead. - `tolowerequalfold` — reports case-insensitive string comparisons using `strings.ToLower`/`ToUpper` that should use `strings.EqualFold`. +- `trimleftright` — flags `strings.TrimLeft`/`TrimRight` calls with a multi-character literal cutset where `TrimPrefix`/`TrimSuffix` was likely intended. - `uncheckedtypeassertion` — reports single-value type assertions where unchecked panics are possible. - `wgdonenotdeferred` — reports non-deferred `sync.WaitGroup.Done()` calls that can deadlock on panics or early returns. - `writebytestring` — reports `w.Write([]byte(s))` calls where `s` is a string, which can be replaced with `io.WriteString` to avoid an unnecessary `[]byte` allocation. @@ -115,6 +116,7 @@ This package currently provides custom Go analyzers in the following subpackages | `timeafterleak` | Custom `go/analysis` analyzer that flags `time.After` in `select` cases inside loops that leak a timer channel on each iteration when another case fires first | | `timesleepnocontext` | Custom `go/analysis` analyzer that flags `time.Sleep` calls in context-aware functions | | `tolowerequalfold` | Custom `go/analysis` analyzer that flags case-insensitive comparisons via `strings.ToLower`/`ToUpper` that should use `strings.EqualFold` | +| `trimleftright` | Custom `go/analysis` analyzer that flags likely mistaken `strings.TrimLeft`/`TrimRight` calls using a multi-character literal cutset where `TrimPrefix`/`TrimSuffix` was likely intended | | `uncheckedtypeassertion` | Custom `go/analysis` analyzer that flags unchecked single-value type assertions | | `wgdonenotdeferred` | Custom `go/analysis` analyzer that flags non-deferred `sync.WaitGroup.Done()` calls | | `writebytestring` | Custom `go/analysis` analyzer that flags `w.Write([]byte(s))` calls where `s` is a string that can be replaced with `io.WriteString` | @@ -234,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/workflow/auto_update_workflow.go b/pkg/workflow/auto_update_workflow.go index 75874c36601..6aeaa71ec71 100644 --- a/pkg/workflow/auto_update_workflow.go +++ b/pkg/workflow/auto_update_workflow.go @@ -182,7 +182,8 @@ The schedule is pinned to the custom cron expression configured in aw.json.` 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 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.` }