diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index b3238e040fc..b243a781cfe 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -35,6 +35,11 @@ name: Agentic Maintenance on: schedule: - cron: "37 */2 * * *" # Every 2 hours (based on minimum expires: 1 days) + push: + branches: + - main + paths: + - '.github/workflows/*.md' workflow_dispatch: inputs: operation: @@ -84,7 +89,7 @@ permissions: {} jobs: close-expired-entities: - if: ${{ (!(github.event.repository.fork)) && (github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_call' || inputs.operation == '') }} + if: ${{ (!(github.event.repository.fork)) && github.event_name != 'push' && (github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_call' || inputs.operation == '') }} runs-on: ubuntu-slim permissions: discussions: write @@ -131,7 +136,7 @@ jobs: await main(); cleanup-cache-memory: - if: ${{ (!(github.event.repository.fork)) && (github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_call' || inputs.operation == '' || inputs.operation == 'clean_cache_memories') }} + if: ${{ (!(github.event.repository.fork)) && github.event_name != 'push' && (github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_call' || inputs.operation == '' || inputs.operation == 'clean_cache_memories') }} runs-on: ubuntu-slim permissions: actions: write @@ -552,6 +557,9 @@ jobs: compile-workflows: if: ${{ (!(github.event.repository.fork)) && (github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_call' || inputs.operation == '') }} runs-on: ubuntu-slim + concurrency: + group: ${{ github.workflow }}-compile-workflows-${{ github.repository }} + cancel-in-progress: true permissions: contents: read issues: write @@ -590,7 +598,7 @@ jobs: await main(); secret-validation: - if: ${{ (!(github.event.repository.fork)) && (github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_call' || inputs.operation == '') }} + if: ${{ (!(github.event.repository.fork)) && github.event_name != 'push' && (github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_call' || inputs.operation == '') }} runs-on: ubuntu-slim permissions: contents: read diff --git a/pkg/cli/compile_post_processing.go b/pkg/cli/compile_post_processing.go index 20e7473e0ba..db0f2a477c6 100644 --- a/pkg/cli/compile_post_processing.go +++ b/pkg/cli/compile_post_processing.go @@ -75,7 +75,7 @@ func generateMaintenanceWorkflowWrapper( repoConfig = nil } - if err := workflow.GenerateMaintenanceWorkflow(workflowDataList, workflowsDir, compiler.GetVersion(), compiler.GetActionMode(), compiler.GetActionTag(), verbose, repoConfig); err != nil { + if err := workflow.GenerateMaintenanceWorkflow(workflowDataList, workflowsDir, compiler.GetVersion(), compiler.GetActionMode(), compiler.GetActionTag(), verbose, repoConfig, compiler.GetRepositorySlug()); err != nil { if strict { return fmt.Errorf("failed to generate maintenance workflow: %w", err) } diff --git a/pkg/cli/init.go b/pkg/cli/init.go index 897a304ef26..1b6b23f9989 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -238,7 +238,7 @@ func ensureMaintenanceWorkflow(verbose bool) error { repoConfig = nil } - if err := workflow.GenerateMaintenanceWorkflow(workflowDataList, workflowsDir, GetVersion(), compiler.GetActionMode(), compiler.GetActionTag(), verbose, repoConfig); err != nil { + if err := workflow.GenerateMaintenanceWorkflow(workflowDataList, workflowsDir, GetVersion(), compiler.GetActionMode(), compiler.GetActionTag(), verbose, repoConfig, compiler.GetRepositorySlug()); err != nil { return fmt.Errorf("failed to generate maintenance workflow: %w", err) } diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index f550cfe590a..8e77ecf56cc 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -278,6 +278,11 @@ func (c *Compiler) SetRepositorySlug(slug string) { c.repositorySlug = slug } +// GetRepositorySlug returns the repository slug (owner/repo) set on this compiler instance. +func (c *Compiler) GetRepositorySlug() string { + return c.repositorySlug +} + // GetScheduleWarnings returns all accumulated schedule warnings for this compiler instance func (c *Compiler) GetScheduleWarnings() []string { return c.scheduleWarnings diff --git a/pkg/workflow/maintenance_conditions.go b/pkg/workflow/maintenance_conditions.go index 70f01544113..176a8a80902 100644 --- a/pkg/workflow/maintenance_conditions.go +++ b/pkg/workflow/maintenance_conditions.go @@ -33,31 +33,53 @@ func buildNotDispatchOrCallOrEmptyOperation() ConditionNode { ) } -// buildNotForkAndScheduledOrOperation creates a condition for jobs that run on -// schedule (or empty operation) AND when a specific operation is selected. -// Condition: !fork && (not_dispatch_or_call || operation == ” || operation == op) -func buildNotForkAndScheduledOrOperation(operation string) ConditionNode { - maintenanceConditionsLog.Printf("Building not-fork-and-scheduled-or-operation condition: %s", operation) +// buildNotForkAndScheduled creates a condition for jobs that should run on any +// non-dispatch/call event including push, or on workflow_dispatch/workflow_call +// with an empty operation, and never on forks. Unlike buildNotForkAndScheduleOnly, +// this function does NOT exclude push events. +// Condition: !fork && ((event_name != 'workflow_dispatch' && event_name != 'workflow_call') || operation == ”) +func buildNotForkAndScheduled() ConditionNode { return BuildAnd( buildNotForkCondition(), - BuildOr( - buildNotDispatchOrCallOrEmptyOperation(), - BuildEquals( - BuildPropertyAccess("inputs.operation"), - BuildStringLiteral(operation), + buildNotDispatchOrCallOrEmptyOperation(), + ) +} + +// buildNotForkAndScheduleOnly creates a condition for jobs that should run on schedule +// (or empty dispatch/call) but NOT on push events, and never on forks. +func buildNotForkAndScheduleOnly() ConditionNode { + return BuildAnd( + buildNotForkCondition(), + BuildAnd( + BuildNotEquals( + BuildPropertyAccess("github.event_name"), + BuildStringLiteral("push"), ), + buildNotDispatchOrCallOrEmptyOperation(), ), ) } -// buildNotForkAndScheduled creates a condition for jobs that should run on any -// non-dispatch/call event (e.g. schedule, push) or on workflow_dispatch/workflow_call -// with an empty operation, and never on forks. -// Condition: !fork && ((event_name != 'workflow_dispatch' && event_name != 'workflow_call') || operation == ”) -func buildNotForkAndScheduled() ConditionNode { +// buildNotForkAndScheduleOnlyOrOperation creates a condition for jobs that run on +// schedule (or empty dispatch/call) or when a specific operation is selected, +// but NOT on push events, and never on forks. +func buildNotForkAndScheduleOnlyOrOperation(operation string) ConditionNode { + maintenanceConditionsLog.Printf("Building not-fork-and-schedule-only-or-operation condition: %s", operation) return BuildAnd( buildNotForkCondition(), - buildNotDispatchOrCallOrEmptyOperation(), + BuildAnd( + BuildNotEquals( + BuildPropertyAccess("github.event_name"), + BuildStringLiteral("push"), + ), + BuildOr( + buildNotDispatchOrCallOrEmptyOperation(), + BuildEquals( + BuildPropertyAccess("inputs.operation"), + BuildStringLiteral(operation), + ), + ), + ), ) } diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index ed521b07873..7e64b7d9074 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/logger" @@ -83,11 +84,37 @@ func getCLICmdPrefix(actionMode ActionMode) string { return "gh aw" } +// FetchDefaultBranch queries the GitHub API to determine the default branch of the +// given repository slug (owner/repo). Returns "main" as a fallback when the slug is +// empty, not in owner/repo format, or when the API call fails. +func FetchDefaultBranch(slug string) string { + const fallback = "main" + if slug == "" || strings.Count(slug, "/") != 1 { + maintenanceLog.Printf("No valid repository slug, using default branch fallback: %s", fallback) + return fallback + } + maintenanceLog.Printf("Fetching default branch for repository: %s", slug) + output, err := RunGH("Fetching default branch...", "api", "/repos/"+slug, "--jq", ".default_branch") + if err != nil { + maintenanceLog.Printf("Failed to fetch default branch for %s: %v, falling back to %s", slug, err, fallback) + return fallback + } + branch := strings.TrimSpace(string(output)) + if branch == "" { + maintenanceLog.Printf("Empty default branch response for %s, falling back to %s", slug, fallback) + return fallback + } + maintenanceLog.Printf("Default branch for %s: %s", slug, branch) + return branch +} + // GenerateMaintenanceWorkflow generates the agentics-maintenance.yml workflow // if any workflows use the expires field for discussions or issues. // When repoConfig is non-nil and repoConfig.MaintenanceDisabled is true the // maintenance workflow is deleted and the function returns immediately. -func GenerateMaintenanceWorkflow(workflowDataList []*WorkflowData, workflowDir string, version string, actionMode ActionMode, actionTag string, verbose bool, repoConfig *RepoConfig) error { +// repoSlug is the owner/repo slug used to determine the default branch for the push +// trigger; pass an empty string to fall back to "main". +func GenerateMaintenanceWorkflow(workflowDataList []*WorkflowData, workflowDir string, version string, actionMode ActionMode, actionTag string, verbose bool, repoConfig *RepoConfig, repoSlug string) error { maintenanceLog.Print("Checking if maintenance workflow is needed") // Respect explicit opt-out from aw.json: maintenance: false @@ -144,8 +171,12 @@ func GenerateMaintenanceWorkflow(workflowDataList []*WorkflowData, workflowDir s cronSchedule, scheduleDesc := generateMaintenanceCron(minExpiresDays) maintenanceLog.Printf("Maintenance schedule: %s (%s)", cronSchedule, scheduleDesc) + // Fetch the default branch for the push trigger (dev mode only) + // Resolved here to avoid passing it through multiple layers; empty slug falls back to "main" + defaultBranch := FetchDefaultBranch(repoSlug) + // Generate the YAML content for the maintenance workflow - content := buildMaintenanceWorkflowYAML(cronSchedule, scheduleDesc, minExpiresDays, runsOnValue, actionMode, version, actionTag, resolver, configuredRunsOn) + content := buildMaintenanceWorkflowYAML(cronSchedule, scheduleDesc, minExpiresDays, runsOnValue, actionMode, version, actionTag, resolver, configuredRunsOn, defaultBranch) // Write the maintenance workflow file maintenanceFile := filepath.Join(workflowDir, "agentics-maintenance.yml") diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 51a3991a18e..4c2d37f69ec 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -150,7 +150,7 @@ func TestGenerateMaintenanceWorkflow_WithExpires(t *testing.T) { tmpDir := t.TempDir() // Call GenerateMaintenanceWorkflow - err := GenerateMaintenanceWorkflow(tt.workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + err := GenerateMaintenanceWorkflow(tt.workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") // Check error expectation if tt.expectError && err == nil { @@ -239,7 +239,7 @@ func TestGenerateMaintenanceWorkflow_DeletesExistingFile(t *testing.T) { } // Call GenerateMaintenanceWorkflow - err := GenerateMaintenanceWorkflow(tt.workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + err := GenerateMaintenanceWorkflow(tt.workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Errorf("Unexpected error: %v", err) } @@ -271,7 +271,7 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -536,6 +536,128 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } } +func TestGenerateMaintenanceWorkflow_PushTrigger(t *testing.T) { + const jobSectionSearchRange = 500 + + workflowDataList := []*WorkflowData{ + { + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{ + Expires: 48, + }, + }, + }, + } + + t.Run("dev mode includes push trigger on main for workflow md files", func(t *testing.T) { + tmpDir := t.TempDir() + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + content, err := os.ReadFile(filepath.Join(tmpDir, "agentics-maintenance.yml")) + if err != nil { + t.Fatalf("Expected maintenance workflow to be generated: %v", err) + } + yaml := string(content) + + if !strings.Contains(yaml, " push:") { + t.Error("Dev mode workflow should include push trigger") + } + if !strings.Contains(yaml, " - main") { + t.Error("Dev mode push trigger should target main branch (fallback when slug is empty)") + } + if !strings.Contains(yaml, " - '.github/workflows/*.md'") { + t.Error("Dev mode push trigger should target .github/workflows/*.md paths") + } + }) + + t.Run("dev mode uses custom default branch from buildMaintenanceWorkflowYAML", func(t *testing.T) { + // Call buildMaintenanceWorkflowYAML directly to test the branch substitution + // without needing a live GitHub API call (FetchDefaultBranch falls back to "main" with no slug) + yaml := buildMaintenanceWorkflowYAML("37 */2 * * *", "Every 2 hours", 1, "ubuntu-slim", ActionModeDev, "v1.0.0", "", nil, nil, "develop") + if !strings.Contains(yaml, " - develop") { + t.Errorf("Push trigger should use the provided default branch 'develop', got:\n%s", yaml[:min(500, len(yaml))]) + } + if strings.Contains(yaml, " - main") { + t.Errorf("Push trigger should not contain hardcoded 'main' when 'develop' is specified") + } + }) + + t.Run("release mode does not include push trigger", func(t *testing.T) { + tmpDir := t.TempDir() + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeRelease, "", false, nil, "") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + content, err := os.ReadFile(filepath.Join(tmpDir, "agentics-maintenance.yml")) + if err != nil { + t.Fatalf("Expected maintenance workflow to be generated: %v", err) + } + yaml := string(content) + + if strings.Contains(yaml, " push:") { + t.Error("Release mode workflow should NOT include push trigger") + } + }) + + t.Run("close-expired-entities and secret-validation exclude push events", func(t *testing.T) { + tmpDir := t.TempDir() + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + content, err := os.ReadFile(filepath.Join(tmpDir, "agentics-maintenance.yml")) + if err != nil { + t.Fatalf("Expected maintenance workflow to be generated: %v", err) + } + yaml := string(content) + pushExclusionCondition := "github.event_name != 'push'" + + scheduleOnlyJobs := []string{"close-expired-entities:", "secret-validation:"} + for _, job := range scheduleOnlyJobs { + jobIdx := strings.Index(yaml, "\n "+job) + if jobIdx == -1 { + t.Errorf("Job %q not found in generated workflow", job) + continue + } + jobSection := yaml[jobIdx : jobIdx+jobSectionSearchRange] + if !strings.Contains(jobSection, pushExclusionCondition) { + t.Errorf("Job %q should exclude push events (%q) but condition is:\n%s", job, pushExclusionCondition, jobSection) + } + } + }) + + t.Run("compile-workflows runs on push events (no push exclusion)", func(t *testing.T) { + tmpDir := t.TempDir() + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + content, err := os.ReadFile(filepath.Join(tmpDir, "agentics-maintenance.yml")) + if err != nil { + t.Fatalf("Expected maintenance workflow to be generated: %v", err) + } + yaml := string(content) + + compileIdx := strings.Index(yaml, "\n compile-workflows:") + if compileIdx == -1 { + t.Fatal("Job compile-workflows not found in generated workflow") + } + jobSection := yaml[compileIdx : compileIdx+jobSectionSearchRange] + if strings.Contains(jobSection, "github.event_name != 'push'") { + t.Errorf("Job compile-workflows should NOT exclude push events, but condition is:\n%s", jobSection) + } + if !strings.Contains(jobSection, "cancel-in-progress: true") { + t.Errorf("Job compile-workflows should have cancel-in-progress concurrency, but got:\n%s", jobSection) + } + if !strings.Contains(jobSection, "github.workflow }}-compile-workflows-${{ github.repository") { + t.Errorf("Job compile-workflows should have a scoped concurrency group, but got:\n%s", jobSection) + } + }) +} + func TestGenerateMaintenanceWorkflow_ActionTag(t *testing.T) { workflowDataList := []*WorkflowData{ { @@ -550,7 +672,7 @@ func TestGenerateMaintenanceWorkflow_ActionTag(t *testing.T) { t.Run("release mode with action-tag uses remote ref", func(t *testing.T) { tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeRelease, "v0.47.4", false, nil) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeRelease, "v0.47.4", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -586,7 +708,7 @@ func TestGenerateMaintenanceWorkflow_ActionTag(t *testing.T) { }, } - err := GenerateMaintenanceWorkflow(workflowDataListWithResolver, tmpDir, "v1.0.0", ActionModeRelease, "v0.47.4", false, nil) + err := GenerateMaintenanceWorkflow(workflowDataListWithResolver, tmpDir, "v1.0.0", ActionModeRelease, "v0.47.4", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -605,7 +727,7 @@ func TestGenerateMaintenanceWorkflow_ActionTag(t *testing.T) { t.Run("dev mode ignores action-tag and uses local path", func(t *testing.T) { tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "v0.47.4", false, nil) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "v0.47.4", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -718,7 +840,7 @@ func TestGenerateMaintenanceWorkflow_RunOperationCLICodegen(t *testing.T) { t.Run("dev mode run_operation uses build from source", func(t *testing.T) { tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -737,7 +859,7 @@ func TestGenerateMaintenanceWorkflow_RunOperationCLICodegen(t *testing.T) { t.Run("release mode run_operation uses setup-cli action not gh extension install", func(t *testing.T) { tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeRelease, "v1.0.0", false, nil) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeRelease, "v1.0.0", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -759,7 +881,7 @@ func TestGenerateMaintenanceWorkflow_RunOperationCLICodegen(t *testing.T) { t.Run("dev mode compile_workflows uses same codegen as run_operation", func(t *testing.T) { tmpDir := t.TempDir() - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -807,7 +929,7 @@ func TestGenerateMaintenanceWorkflow_SetupCLISHAPinning(t *testing.T) { cache.Set("github/gh-aw/actions/setup", "v1.0.0", "dddddddddddddddddddddddddddddddddddddddd") resolver := NewActionResolver(cache) - err := GenerateMaintenanceWorkflow(workflowDataListWithResolver(resolver), tmpDir, "v1.0.0", ActionModeRelease, "v1.0.0", false, nil) + err := GenerateMaintenanceWorkflow(workflowDataListWithResolver(resolver), tmpDir, "v1.0.0", ActionModeRelease, "v1.0.0", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -846,7 +968,7 @@ func TestGenerateMaintenanceWorkflow_RepoConfig(t *testing.T) { cfg := &RepoConfig{ Maintenance: &MaintenanceConfig{RunsOn: RunsOnValue{"my-custom-runner"}}, } - err := GenerateMaintenanceWorkflow(makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg) + err := GenerateMaintenanceWorkflow(makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -869,7 +991,7 @@ func TestGenerateMaintenanceWorkflow_RepoConfig(t *testing.T) { cfg := &RepoConfig{ Maintenance: &MaintenanceConfig{RunsOn: RunsOnValue{"self-hosted", "linux"}}, } - err := GenerateMaintenanceWorkflow(makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg) + err := GenerateMaintenanceWorkflow(makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -891,7 +1013,7 @@ func TestGenerateMaintenanceWorkflow_RepoConfig(t *testing.T) { t.Fatalf("Failed to write pre-existing file: %v", err) } cfg := &RepoConfig{MaintenanceDisabled: true} - err := GenerateMaintenanceWorkflow(makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg) + err := GenerateMaintenanceWorkflow(makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -903,7 +1025,7 @@ func TestGenerateMaintenanceWorkflow_RepoConfig(t *testing.T) { t.Run("maintenance disabled skips generation even with expires", func(t *testing.T) { tmpDir := t.TempDir() cfg := &RepoConfig{MaintenanceDisabled: true} - err := GenerateMaintenanceWorkflow(makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg) + err := GenerateMaintenanceWorkflow(makeList(), tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -925,7 +1047,7 @@ func TestGenerateMaintenanceWorkflow_RepoConfig(t *testing.T) { } cfg := &RepoConfig{MaintenanceDisabled: true} // The function must succeed (no error), even though a warning is printed. - err := GenerateMaintenanceWorkflow(list, tmpDir, "v1.0.0", ActionModeDev, "", false, cfg) + err := GenerateMaintenanceWorkflow(list, tmpDir, "v1.0.0", ActionModeDev, "", false, cfg, "") if err != nil { t.Fatalf("Expected no error when maintenance is disabled with expires, got: %v", err) } @@ -1197,7 +1319,7 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { }, } - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1251,7 +1373,7 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { }, } - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1280,7 +1402,7 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { }, } - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1325,7 +1447,7 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { }, } - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1354,7 +1476,7 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { }, } - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -1399,7 +1521,7 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { }, } - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") if err != nil { t.Fatalf("Unexpected error: %v", err) } diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index bda1644251e..57eaf5d996c 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -20,8 +20,9 @@ func buildMaintenanceWorkflowYAML( version, actionTag string, resolver ActionSHAResolver, configuredRunsOn RunsOnValue, + defaultBranch string, ) string { - maintenanceWorkflowYAMLLog.Printf("Building maintenance workflow YAML: actionMode=%s minExpiresDays=%d cronSchedule=%q", actionMode, minExpiresDays, cronSchedule) + maintenanceWorkflowYAMLLog.Printf("Building maintenance workflow YAML: actionMode=%s minExpiresDays=%d cronSchedule=%q defaultBranch=%q", actionMode, minExpiresDays, cronSchedule, defaultBranch) var yaml strings.Builder @@ -44,7 +45,19 @@ Schedule frequency is automatically determined by the shortest expiration time.` on: schedule: - cron: "` + cronSchedule + `" # ` + scheduleDesc + ` (based on minimum expires: ` + strconv.Itoa(minExpiresDays) + ` days) - workflow_dispatch: +`) + + // Add push trigger in dev mode so compile-workflows runs when workflow files change + if actionMode == ActionModeDev { + yaml.WriteString(` push: + branches: + - ` + defaultBranch + ` + paths: + - '.github/workflows/*.md' +`) + } + + yaml.WriteString(` workflow_dispatch: inputs: operation: description: 'Optional maintenance operation to run' @@ -93,7 +106,7 @@ permissions: {} jobs: close-expired-entities: - if: ${{ ` + RenderCondition(buildNotForkAndScheduled()) + ` }} + if: ${{ ` + RenderCondition(buildNotForkAndScheduleOnly()) + ` }} runs-on: ` + runsOnValue + ` permissions: discussions: write @@ -161,7 +174,7 @@ jobs: // Add cleanup-cache-memory job for scheduled runs and clean_cache_memories operation // This job lists all caches starting with "memory-", groups them by key prefix, // keeps the latest run ID per group, and deletes the rest. - cleanupCacheCondition := buildNotForkAndScheduledOrOperation("clean_cache_memories") + cleanupCacheCondition := buildNotForkAndScheduleOnlyOrOperation("clean_cache_memories") yaml.WriteString(` cleanup-cache-memory: if: ${{ ` + RenderCondition(cleanupCacheCondition) + ` }} @@ -611,6 +624,9 @@ jobs: compile-workflows: if: ${{ ` + RenderCondition(buildNotForkAndScheduled()) + ` }} runs-on: ` + runsOnValue + ` + concurrency: + group: ${{ github.workflow }}-compile-workflows-${{ github.repository }} + cancel-in-progress: true permissions: contents: read issues: write @@ -646,7 +662,7 @@ jobs: await main(); secret-validation: - if: ${{ ` + RenderCondition(buildNotForkAndScheduled()) + ` }} + if: ${{ ` + RenderCondition(buildNotForkAndScheduleOnly()) + ` }} runs-on: ` + runsOnValue + ` permissions: contents: read diff --git a/pkg/workflow/side_repo_maintenance_integration_test.go b/pkg/workflow/side_repo_maintenance_integration_test.go index 7e0080ee35b..1234d422832 100644 --- a/pkg/workflow/side_repo_maintenance_integration_test.go +++ b/pkg/workflow/side_repo_maintenance_integration_test.go @@ -58,7 +58,7 @@ This workflow operates on a separate repository. workflowDataList, tmpDir := compileSideRepoWorkflow(t, workflowContent) - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") require.NoError(t, err, "generate maintenance workflow") sideRepoFile := filepath.Join(tmpDir, "agentics-maintenance-my-org-target-repo.yml") @@ -159,7 +159,7 @@ Create issues that expire after 14 days. workflowDataList, tmpDir := compileSideRepoWorkflow(t, workflowContent) - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") require.NoError(t, err, "generate maintenance workflow") sideRepoFile := filepath.Join(tmpDir, "agentics-maintenance-corp-infra-tools.yml") @@ -209,7 +209,7 @@ checkout: workflowDataList, tmpDir := compileSideRepoWorkflow(t, workflowContent) - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") require.NoError(t, err, "generate maintenance workflow") sideRepoFile := filepath.Join(tmpDir, "agentics-maintenance-acme-shared-services.yml") @@ -245,7 +245,7 @@ checkout: workflowDataList, tmpDir := compileSideRepoWorkflow(t, workflowContent) - err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil) + err := GenerateMaintenanceWorkflow(workflowDataList, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "") require.NoError(t, err, "generate maintenance workflow") // No side-repo file should be created because the repository is an expression. @@ -304,7 +304,7 @@ safe-outputs: } { t.Run(tc.repo, func(t *testing.T) { wdl, tmpDir := compileSideRepoWorkflow(t, makeContent(tc.repo)) - require.NoError(t, GenerateMaintenanceWorkflow(wdl, tmpDir, "v1.0.0", ActionModeDev, "", false, nil)) + require.NoError(t, GenerateMaintenanceWorkflow(wdl, tmpDir, "v1.0.0", ActionModeDev, "", false, nil, "")) slug := sanitizeRepoForFilename(tc.repo) sideFile := filepath.Join(tmpDir, "agentics-maintenance-"+slug+".yml")