Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/agentic-auto-upgrade.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/aw.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"auto_upgrade": true,
"auto_upgrade": { "cron": "0 9 * * 1" },
"ghes": true,
"maintenance": {
"action_failure_issue_expires": 12,
Expand Down
44 changes: 44 additions & 0 deletions docs/adr/46521-configurable-auto-upgrade-cron-schedule.md
Original file line number Diff line number Diff line change
@@ -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.*
1 change: 1 addition & 0 deletions pkg/linters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 21 additions & 2 deletions pkg/parser/schemas/repo_config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Comment on lines 37 to +39
{
"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.",
Expand Down
47 changes: 39 additions & 8 deletions pkg/workflow/auto_update_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment on lines +91 to +93
} 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 == "" {
Expand All @@ -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)
Expand Down Expand Up @@ -151,23 +163,42 @@ 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)

return header + `name: Agentic Auto-Upgrade

on:
schedule:
- cron: "` + cronSchedule + `" # Weekly (auto-upgrade)
- cron: "` + cronSchedule + `" # ` + scheduleComment + `
workflow_dispatch:

permissions:
Expand Down
43 changes: 43 additions & 0 deletions pkg/workflow/auto_update_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
12 changes: 12 additions & 0 deletions pkg/workflow/maintenance_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ func GenerateMaintenanceWorkflow(ctx context.Context, opts GenerateMaintenanceWo
Version: version,
ActionTag: actionTag,
Resolver: resolver,
CustomCron: autoUpgradeCronFrom(repoConfig),
})
}

Expand Down Expand Up @@ -237,6 +238,7 @@ func GenerateMaintenanceWorkflow(ctx context.Context, opts GenerateMaintenanceWo
Version: version,
ActionTag: actionTag,
Resolver: resolver,
CustomCron: autoUpgradeCronFrom(repoConfig),
})
}

Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading