diff --git a/.github/workflows/agentic-auto-upgrade.yml b/.github/workflows/agentic-auto-upgrade.yml index 7035101ee35..6aff9d33647 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: "21 3 * * 5" # Weekly (auto-upgrade) + - cron: "41 23 * * 1" # Weekly (auto-upgrade) workflow_dispatch: permissions: diff --git a/pkg/workflow/auto_update_workflow.go b/pkg/workflow/auto_update_workflow.go index 18e0949f925..77c340137c5 100644 --- a/pkg/workflow/auto_update_workflow.go +++ b/pkg/workflow/auto_update_workflow.go @@ -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) @@ -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() @@ -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 } diff --git a/pkg/workflow/auto_update_workflow_test.go b/pkg/workflow/auto_update_workflow_test.go index 60447fe1e8c..2773ca75fb1 100644 --- a/pkg/workflow/auto_update_workflow_test.go +++ b/pkg/workflow/auto_update_workflow_test.go @@ -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)) @@ -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) { @@ -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.