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
2 changes: 1 addition & 1 deletion .github/workflows/agentic-auto-upgrade.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ name: Agentic Auto-Upgrade

on:
schedule:
- cron: "21 3 * * 5" # Weekly (auto-upgrade)
- cron: "41 23 * * 1" # Weekly (auto-upgrade)
workflow_dispatch:

permissions:
Expand Down
32 changes: 24 additions & 8 deletions pkg/workflow/auto_update_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,12 @@ func GenerateAutoUpdateWorkflow(opts GenerateAutoUpdateWorkflowOptions) error {
return nil
}

seed := buildAutoUpdateSeed(opts.RepoSlug)
actionMode := opts.ActionMode
if actionMode == "" {
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)
Expand All @@ -94,10 +99,6 @@ func GenerateAutoUpdateWorkflow(opts GenerateAutoUpdateWorkflowOptions) error {
githubScriptPin = getActionPin("actions/github-script")
}

actionMode := opts.ActionMode
if actionMode == "" {
actionMode = ActionModeDev
}
ctx := opts.Context
if ctx == nil {
ctx = context.Background()
Expand All @@ -123,9 +124,24 @@ func GenerateAutoUpdateWorkflow(opts GenerateAutoUpdateWorkflowOptions) error {
}

// buildAutoUpdateSeed returns the deterministic seed string used to scatter the
// FUZZY:WEEKLY cron schedule. It combines the repo slug with the fixed workflow
// identifier so that repositories scatter to distinct time slots.
func buildAutoUpdateSeed(repoSlug string) string {
// FUZZY:WEEKLY cron schedule.
//
// In dev mode a stable "dev/" prefix is used instead of the slug, which may not
// be available (e.g. in sandbox environments where the git remote URL is a
// localhost proxy). This mirrors the behaviour of normalizeScheduleString in
// schedule_preprocessing.go and prevents the generated schedule from changing
// between dev builds when --schedule-seed is sometimes provided and sometimes not.
//
// In all other modes (release, action, script) the repo slug is incorporated so
// that different repositories scatter to distinct time slots. Note: released
// binaries auto-detect ActionModeAction, not ActionModeRelease, so checking only
// IsRelease() would cause ActionModeAction builds to silently use the dev seed.
func buildAutoUpdateSeed(repoSlug string, actionMode ActionMode) string {
if actionMode.IsDev() {
// Dev mode: use a fixed prefix that does not depend on git remote detection.
return "dev/" + autoUpdateWorkflowIdentifier
}
// Release/action/script mode: incorporate repo slug for per-repo scattering.
if repoSlug != "" {
return repoSlug + "/" + autoUpdateWorkflowIdentifier
}
Expand Down
58 changes: 55 additions & 3 deletions pkg/workflow/auto_update_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,19 @@ func TestGenerateAutoUpdateWorkflow_DifferentReposDifferentCron(t *testing.T) {
dir1 := t.TempDir()
dir2 := t.TempDir()

// Per-repo schedule scattering applies in all non-dev modes.
// Use ActionModeAction since that is what released binaries actually detect.
require.NoError(t, GenerateAutoUpdateWorkflow(GenerateAutoUpdateWorkflowOptions{
WorkflowDir: dir1,
Enabled: true,
RepoSlug: "org1/repo-alpha",
ActionMode: ActionModeAction,
}))
require.NoError(t, GenerateAutoUpdateWorkflow(GenerateAutoUpdateWorkflowOptions{
WorkflowDir: dir2,
Enabled: true,
RepoSlug: "org2/repo-beta",
ActionMode: ActionModeAction,
}))

data1, err := os.ReadFile(filepath.Join(dir1, AutoUpdateWorkflowFileName))
Expand All @@ -125,7 +129,7 @@ func TestGenerateAutoUpdateWorkflow_DifferentReposDifferentCron(t *testing.T) {
assert.NotEmpty(t, cron2, "cron should be non-empty for org2/repo-beta")
// Schedules are scattered by hash — different repos should typically differ.
// This is a best-effort check; hash collisions are possible but unlikely for these slugs.
assert.NotEqual(t, cron1, cron2, "different repo slugs should produce different cron schedules")
assert.NotEqual(t, cron1, cron2, "different repo slugs should produce different cron schedules in action mode")
}

func TestGenerateAutoUpdateWorkflow_NoRepoSlug(t *testing.T) {
Expand Down Expand Up @@ -180,9 +184,57 @@ func TestGenerateAutoUpdateWorkflow_ReleaseModeUsesGhAwPrefix(t *testing.T) {
assert.NotContains(t, string(content), "Build gh-aw", "should not build gh-aw from source in release mode")
}

func TestGenerateAutoUpdateWorkflow_DevModeScheduleStable(t *testing.T) {
// Dev mode must produce the same schedule regardless of whether --schedule-seed /
// a repo slug is provided. This is the key stability property: agents and CI that
// call `gh aw compile` without --schedule-seed should not cause agentic-auto-upgrade.yml
// to differ from a build that passes --schedule-seed.
dir1 := t.TempDir()
dir2 := t.TempDir()

require.NoError(t, GenerateAutoUpdateWorkflow(GenerateAutoUpdateWorkflowOptions{
WorkflowDir: dir1,
Enabled: true,
RepoSlug: "github/gh-aw", // simulates --schedule-seed github/gh-aw
ActionMode: ActionModeDev,
}))
require.NoError(t, GenerateAutoUpdateWorkflow(GenerateAutoUpdateWorkflowOptions{
WorkflowDir: dir2,
Enabled: true,
RepoSlug: "", // simulates sandbox/CI where git remote detection yields empty slug
ActionMode: ActionModeDev,
}))

data1, err := os.ReadFile(filepath.Join(dir1, AutoUpdateWorkflowFileName))
require.NoError(t, err)
data2, err := os.ReadFile(filepath.Join(dir2, AutoUpdateWorkflowFileName))
require.NoError(t, err)

cron1 := extractCronLine(string(data1))
cron2 := extractCronLine(string(data2))
assert.NotEmpty(t, cron1, "cron should be present with slug in dev mode")
assert.NotEmpty(t, cron2, "cron should be present without slug in dev mode")
assert.Equal(t, cron1, cron2, "dev mode schedule must be identical regardless of repo slug")
}

func TestBuildAutoUpdateSeed(t *testing.T) {
assert.Equal(t, "owner/repo/agentic-auto-upgrade", buildAutoUpdateSeed("owner/repo"))
assert.Equal(t, "agentic-auto-upgrade", buildAutoUpdateSeed(""))
// Release mode: incorporates repo slug for per-repo scattering.
assert.Equal(t, "owner/repo/agentic-auto-upgrade", buildAutoUpdateSeed("owner/repo", ActionModeRelease))
assert.Equal(t, "agentic-auto-upgrade", buildAutoUpdateSeed("", ActionModeRelease))

// Action mode (used by released binaries): also incorporates repo slug for per-repo scattering.
assert.Equal(t, "owner/repo/agentic-auto-upgrade", buildAutoUpdateSeed("owner/repo", ActionModeAction))
assert.Equal(t, "agentic-auto-upgrade", buildAutoUpdateSeed("", ActionModeAction))

// Script mode: also incorporates repo slug for per-repo scattering.
assert.Equal(t, "owner/repo/agentic-auto-upgrade", buildAutoUpdateSeed("owner/repo", ActionModeScript))
assert.Equal(t, "agentic-auto-upgrade", buildAutoUpdateSeed("", ActionModeScript))

// Dev mode: always uses the stable "dev/" prefix regardless of repo slug,
// so that the schedule does not change depending on whether --schedule-seed
// is passed or whether git remote detection succeeds.
assert.Equal(t, "dev/agentic-auto-upgrade", buildAutoUpdateSeed("owner/repo", ActionModeDev))
assert.Equal(t, "dev/agentic-auto-upgrade", buildAutoUpdateSeed("", ActionModeDev))
}

// extractCronLine returns the cron expression from the first `- cron:` line in the YAML.
Expand Down