diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index 35b466e740c..61aaf2c971c 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -94,13 +94,11 @@ on: permissions: {} jobs: - close-expired-entities: + close-expired-discussions: 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 - issues: write - pull-requests: write steps: - name: Checkout actions folder uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -123,6 +121,24 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/close_expired_discussions.cjs'); await main(); + close-expired-issues: + 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: + issues: write + steps: + - name: Checkout actions folder + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + sparse-checkout: | + actions + clean: false + persist-credentials: false + + - name: Setup Scripts + uses: ./actions/setup + with: + destination: ${{ runner.temp }}/gh-aw/actions - name: Close expired issues uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -132,6 +148,24 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/close_expired_issues.cjs'); await main(); + close-expired-pull-requests: + 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: + pull-requests: write + steps: + - name: Checkout actions folder + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + sparse-checkout: | + actions + clean: false + persist-credentials: false + + - name: Setup Scripts + uses: ./actions/setup + with: + destination: ${{ runner.temp }}/gh-aw/actions - name: Close expired pull requests uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/docs/adr/43300-scoped-maintenance-job-permissions.md b/docs/adr/43300-scoped-maintenance-job-permissions.md new file mode 100644 index 00000000000..f39943f932c --- /dev/null +++ b/docs/adr/43300-scoped-maintenance-job-permissions.md @@ -0,0 +1,57 @@ +# ADR-43300: Scoped Permissions for Maintenance Jobs via Job Splitting and Selective Disablement + +**Date**: 2026-07-04 +**Status**: Draft +**Deciders**: Unknown (Copilot SWE agent, pelikhan) + +--- + +### Context + +The generated `agentics-maintenance.yml` contained a single `close-expired-entities` job that always requested `discussions: write`, `issues: write`, and `pull-requests: write` together — regardless of which entity types a repo actually used. Similarly, jobs such as `apply_safe_outputs` and the label-triggered jobs were always emitted, forcing write scopes on repos that had no need for them. This violated least-privilege principles and increased the blast radius of any token compromise. Repos that handled only issues (no discussions, no PRs) still received all three write permissions. There was no mechanism for repo owners to opt out of individual maintenance jobs without forking the generated workflow or disabling maintenance entirely. + +### Decision + +We will split `close-expired-entities` into three separate jobs (`close-expired-discussions`, `close-expired-issues`, `close-expired-pull-requests`), each requesting only the single write permission it needs. We will also add a `maintenance.disabled_jobs` array to `aw.json` that allows repo owners to omit specific maintenance jobs from the generated workflow. Job IDs in the list are normalized (case-insensitive, `_` and `-` treated equivalently). When `apply_safe_outputs` is disabled, the `workflow_call.outputs.applied_run_url` output falls back to `inputs.run_url` to avoid dangling job-output references. When all label-triggered jobs are individually disabled, the `issues: labeled` trigger is suppressed. + +### Alternatives Considered + +#### Alternative 1: Conditional steps within the existing monolithic job + +Keep the single `close-expired-entities` job but add `if:` conditions on each step (discussions, issues, PRs) so only the relevant steps run. This avoids splitting the job definition. + +Why not chosen: The job-level `permissions:` block is evaluated regardless of which steps actually run; GitHub Actions does not support per-step permission narrowing. A repo that disabled the discussions step would still receive `discussions: write` on the runner token — defeating the least-privilege goal. + +#### Alternative 2: Extend the existing `label_triggers: false` pattern with per-job boolean flags + +Instead of a free-form `disabled_jobs` array, add individual boolean fields (e.g., `close_expired_discussions: false`, `apply_safe_outputs: false`) to the maintenance config schema. + +Why not chosen: Each new maintenance job would require a corresponding schema field, making the config schema grow proportionally with the workflow. A string list of job IDs scales to any number of jobs without schema changes, and the normalization strategy (case-insensitive, `_`/`-` equivalence) makes the values human-friendly. The downside is that typos in job IDs silently have no effect — a validation warning or enumeration constraint could be added later. + +#### Alternative 3: Generate separate maintenance workflow variants per repo type + +Detect the repo's entity mix (issues-only, discussions-only, etc.) automatically from repository settings and emit a pre-tailored workflow with only the relevant jobs and permissions. + +Why not chosen: Auto-detection would require additional API calls at compile time and could misclassify repos mid-lifecycle (e.g., a repo that enables discussions after initial setup). Explicit opt-out via `disabled_jobs` gives repo owners deterministic, auditable control without silent automation surprises. + +### Consequences + +#### Positive +- Each `close-expired-*` job now requests only the one write permission it needs, eliminating unnecessary token scopes. +- Repos can omit entire maintenance jobs (including `apply_safe_outputs` and label-triggered jobs) without disabling maintenance globally. +- The `issues: labeled` trigger is automatically suppressed when all label-triggered jobs are disabled, reducing event processing overhead. +- The refactored `writeCloseExpiredJob` helper eliminates code duplication across the three close-expired jobs. + +#### Negative +- The generated `agentics-maintenance.yml` now contains up to three separate jobs where there was previously one, increasing YAML verbosity. +- `disabled_jobs` entries with typos silently have no effect; there is no validation error for unknown job names. [TODO: verify whether a schema `enum` constraint should be added] +- The normalization logic (`_`/`-` equivalence, case-insensitivity) adds a subtle invariant that must be maintained consistently across the config parser, YAML builder, and tests. + +#### Neutral +- Existing repos that do not set `disabled_jobs` see no behavioral change; all jobs continue to be generated as before (backward-compatible default). +- The `workflow_call` output `applied_run_url` changes its source expression when `apply_safe_outputs` is disabled — callers of the maintenance workflow via `workflow_call` should be aware this value may now reflect `inputs.run_url` rather than the job's own output. +- Tests were updated to assert per-job scoped permissions and to cover the new `disabled_jobs` config path. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/docs/src/content/docs/reference/ephemerals.md b/docs/src/content/docs/reference/ephemerals.md index 2f00127ceaa..7a209e0ad3f 100644 --- a/docs/src/content/docs/reference/ephemerals.md +++ b/docs/src/content/docs/reference/ephemerals.md @@ -129,7 +129,12 @@ You can customize the maintenance workflow runner or disable maintenance entirel { "maintenance": { "runs_on": "ubuntu-latest", - "action_failure_issue_expires": 72 + "action_failure_issue_expires": 72, + "disabled_jobs": [ + "close-expired-entities", + "apply_safe_outputs", + "label_apply_safe_outputs" + ] } } ``` @@ -138,6 +143,19 @@ The `runs_on` field accepts a single string or an array of strings for multi-lab The `action_failure_issue_expires` field controls expiration (in hours) for failure issues opened by the conclusion job (including grouped parent issues when `group-reports: true`). The default is `168` (7 days). +The `disabled_jobs` field lets you omit specific maintenance jobs from the generated workflow. Job IDs are case-insensitive, and `_` / `-` are treated equivalently. + +Supported job IDs: + +| Job ID | Effect when disabled | +| --- | --- | +| `close-expired-entities` | Omits all three close-expired cleanup jobs (`close-expired-discussions`, `close-expired-issues`, and `close-expired-pull-requests`). | +| `apply_safe_outputs` | Omits the `safe_outputs` replay job. When this is disabled, `workflow_call.outputs.applied_run_url` falls back to `inputs.run_url`; scheduled and manual runs leave that output empty. | +| `label_disable_agentic_workflow` | Omits the label-triggered disable workflow job. | +| `label_apply_safe_outputs` | Omits the label-triggered safe-outputs replay job. | + +Unrecognized job IDs are rejected during config validation. + See [Self-Hosted Runners](/gh-aw/reference/self-hosted-runners/#configuring-the-maintenance-workflow-runner) for more details. **Disable maintenance entirely:** diff --git a/pkg/parser/schemas/repo_config_schema.json b/pkg/parser/schemas/repo_config_schema.json index 89999cac369..26c576b11c9 100644 --- a/pkg/parser/schemas/repo_config_schema.json +++ b/pkg/parser/schemas/repo_config_schema.json @@ -87,6 +87,16 @@ "description": "Set to false to disable all label-triggered jobs (disable_agentic_workflow, label_apply_safe_outputs, etc.). When absent or true (default), all label-triggered jobs are included in the maintenance workflow.", "type": "boolean" }, + "disabled_jobs": { + "description": "Maintenance workflow job IDs to skip when generating agentics-maintenance.yml. Supported IDs are close-expired-entities, apply_safe_outputs, label_disable_agentic_workflow, and label_apply_safe_outputs. Values are case-insensitive; underscores and hyphens are treated equivalently.", + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "examples": ["close-expired-entities", "apply_safe_outputs", "label_disable_agentic_workflow", "label_apply_safe_outputs"] + }, + "uniqueItems": true + }, "compile": { "description": "Configuration for the compile-workflows maintenance job.", "type": "object", diff --git a/pkg/workflow/github_cli.go b/pkg/workflow/github_cli.go index face5461cef..d6b7eec5cf3 100644 --- a/pkg/workflow/github_cli.go +++ b/pkg/workflow/github_cli.go @@ -43,6 +43,7 @@ func setupGHCommand(ctx context.Context, args ...string) *exec.Cmd { // Check if GH_TOKEN or GITHUB_TOKEN is available ghToken := lookupProcessEnv("GH_TOKEN") githubToken := lookupProcessEnv("GITHUB_TOKEN") + ghHost := lookupProcessEnv("GH_HOST") if ctx == nil { ctx = context.TODO() @@ -59,25 +60,39 @@ func setupGHCommand(ctx context.Context, args ...string) *exec.Cmd { // Only add GH_TOKEN if it's not set but GITHUB_TOKEN is available if ghToken == "" && githubToken != "" { githubCLILog.Printf("GH_TOKEN not set, using GITHUB_TOKEN for gh CLI") - cmd.Env = append(os.Environ(), "GH_TOKEN="+githubToken) + cmd.Env = append(filteredGHCLIEnv(ghToken, githubToken, ghHost), "GH_TOKEN="+githubToken) } - if lookupProcessEnv("GH_HOST") == "" { - host := getDefaultGHHost() - if host != "" && host != "github.com" { - if cmd.Env == nil { - // Build the base env from os.Environ(), filtering out GH_TOKEN when - // the processEnvLookup says it is absent. This prevents the real - // runner GH_TOKEN from leaking into commands that should not have it - // (e.g., under test mocks or when only GH_HOST must be injected). - cmd.Env = filterTokenFromEnv(os.Environ(), ghToken) - } - cmd.Env = append(cmd.Env, "GH_HOST="+host) + if ghHost == "" { + defaultHost := getDefaultGHHost() + if defaultHost != "" && defaultHost != "github.com" && cmd.Env == nil { + cmd.Env = filteredGHCLIEnv(ghToken, githubToken, ghHost) } + SetGHHostEnv(cmd, defaultHost) } return cmd } +func filteredGHCLIEnv(ghToken, githubToken, ghHost string) []string { + env := make([]string, 0, len(os.Environ())+3) + for _, entry := range os.Environ() { + if strings.HasPrefix(entry, "GH_TOKEN=") || strings.HasPrefix(entry, "GITHUB_TOKEN=") || strings.HasPrefix(entry, "GH_HOST=") { + continue + } + env = append(env, entry) + } + if ghToken != "" { + env = append(env, "GH_TOKEN="+ghToken) + } + if githubToken != "" { + env = append(env, "GITHUB_TOKEN="+githubToken) + } + if ghHost != "" { + env = append(env, "GH_HOST="+ghHost) + } + return env +} + // ExecGH wraps gh CLI calls and ensures proper token configuration. // It uses go-gh/v2 to execute gh commands when GH_TOKEN or GITHUB_TOKEN is available, // otherwise falls back to direct exec.Command for backward compatibility. @@ -238,24 +253,6 @@ func RunGHContextWithHost(ctx context.Context, spinnerMessage string, host strin return output, enrichGHError(err) } -// filterTokenFromEnv returns env unchanged when knownToken is non-empty (the token is -// legitimately present), or returns a copy of env with all "GH_TOKEN=…" entries removed -// when knownToken is empty. This prevents the real runner GH_TOKEN from leaking into a -// command's environment when the processEnvLookup indicates no token should be present -// (e.g., under test mocks that override the env lookup). -func filterTokenFromEnv(env []string, knownToken string) []string { - if knownToken != "" { - return env - } - filtered := make([]string, 0, len(env)) - for _, e := range env { - if !strings.HasPrefix(e, "GH_TOKEN=") { - filtered = append(filtered, e) - } - } - return filtered -} - // SetGHHostEnv sets the GH_HOST environment variable on the command for non-github.com hosts. // This is needed for GitHub Enterprise Server (GHES) and Proxima (data residency) instances // because commands like `gh repo view`, `gh pr create`, and `gh run view` do not accept a diff --git a/pkg/workflow/github_cli_test.go b/pkg/workflow/github_cli_test.go index c7c172f02c7..1317118b878 100644 --- a/pkg/workflow/github_cli_test.go +++ b/pkg/workflow/github_cli_test.go @@ -142,15 +142,8 @@ func TestExecGHUsesConfiguredProcessEnvLookup(t *testing.T) { }) t.Run("does not inject gh token when both tokens are absent", func(t *testing.T) { - originalGHToken, ghTokenWasSet := os.LookupEnv("GH_TOKEN") - if ghTokenWasSet { - require.NoError(t, os.Unsetenv("GH_TOKEN")) - } - t.Cleanup(func() { - if ghTokenWasSet { - require.NoError(t, os.Setenv("GH_TOKEN", originalGHToken)) - } - }) + t.Setenv("GH_TOKEN", "ambient-gh-token") + t.Setenv("GITHUB_TOKEN", "ambient-github-token") SetProcessEnvLookup(func(key string) (string, bool) { return "", false @@ -164,6 +157,9 @@ func TestExecGHUsesConfiguredProcessEnvLookup(t *testing.T) { assert.False(t, slices.ContainsFunc(cmd.Env, func(e string) bool { return strings.HasPrefix(e, "GH_TOKEN=") })) + assert.False(t, slices.ContainsFunc(cmd.Env, func(e string) bool { + return strings.HasPrefix(e, "GITHUB_TOKEN=") + })) assert.Contains(t, cmd.Env, "GH_HOST=configured.ghe.com") }) } diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index 51d6cfc30fb..8182081f3ee 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -180,13 +180,15 @@ func GenerateMaintenanceWorkflow(ctx context.Context, opts GenerateMaintenanceWo const defaultRunsOn = "ubuntu-slim" var configuredRunsOn RunsOnValue disableLabelTrigger := true // default: disable label-triggered jobs (opt-in) + var maintenanceConfig *MaintenanceConfig var compileGitHubTokenSecret string enableCompileCreatePullRequest := false if repoConfig != nil && repoConfig.Maintenance != nil { - configuredRunsOn = repoConfig.Maintenance.RunsOn - disableLabelTrigger = !repoConfig.Maintenance.IsLabelTriggerEnabled() - if repoConfig.Maintenance.Compile != nil { - compileGitHubTokenSecret = repoConfig.Maintenance.Compile.CreatePullRequestGitHubToken + maintenanceConfig = repoConfig.Maintenance + configuredRunsOn = maintenanceConfig.RunsOn + disableLabelTrigger = !maintenanceConfig.IsLabelTriggerEnabled() + if maintenanceConfig.Compile != nil { + compileGitHubTokenSecret = maintenanceConfig.Compile.CreatePullRequestGitHubToken enableCompileCreatePullRequest = strings.TrimSpace(compileGitHubTokenSecret) != "" } } @@ -274,6 +276,7 @@ func GenerateMaintenanceWorkflow(ctx context.Context, opts GenerateMaintenanceWo configuredRunsOn: configuredRunsOn, defaultBranch: defaultBranch, disableLabelTrigger: disableLabelTrigger, + maintenanceConfig: maintenanceConfig, compileGitHubToken: getEffectiveMaintenanceGitHubToken(compileGitHubTokenSecret), createCompilePR: enableCompileCreatePullRequest, copilotOrgBilling: copilotOrgBilling, diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 4d9ad3f9d54..81bf90c97ed 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -12,8 +12,25 @@ import ( "github.com/github/gh-aw/pkg/stringutil" "github.com/stretchr/testify/require" + yamlv3 "gopkg.in/yaml.v3" ) +type maintenanceWorkflowCallOutput struct { + Value string `yaml:"value"` +} + +type maintenanceWorkflowCall struct { + Outputs struct { + AppliedRunURL maintenanceWorkflowCallOutput `yaml:"applied_run_url"` + } `yaml:"outputs"` +} + +type maintenanceWorkflowDocument struct { + On struct { + WorkflowCall maintenanceWorkflowCall `yaml:"workflow_call"` + } `yaml:"on"` +} + func TestGenerateMaintenanceCron(t *testing.T) { tests := []struct { name string @@ -435,7 +452,13 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { const runOpSectionSearchRange = 500 // Jobs that should be disabled when any non-dedicated operation is set (cleanup-cache-memory has its own dedicated operation) - disabledJobs := []string{"close-expired-entities:", "compile-workflows:", "secret-validation:"} + disabledJobs := []string{ + "close-expired-discussions:", + "close-expired-issues:", + "close-expired-pull-requests:", + "compile-workflows:", + "secret-validation:", + } for _, job := range disabledJobs { // Find the if: condition for each job jobIdx := strings.Index(yaml, "\n "+job) @@ -450,6 +473,41 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } } + type permissionAssertion struct { + requiredWrite string + forbidden []string + } + closeExpiredPermissions := map[string]permissionAssertion{ + "close-expired-discussions:": { + requiredWrite: "discussions: write", + forbidden: []string{"issues: write", "pull-requests: write"}, + }, + "close-expired-issues:": { + requiredWrite: "issues: write", + forbidden: []string{"discussions: write", "pull-requests: write"}, + }, + "close-expired-pull-requests:": { + requiredWrite: "pull-requests: write", + forbidden: []string{"discussions: write", "issues: write"}, + }, + } + for job, perms := range closeExpiredPermissions { + 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, perms.requiredWrite) { + t.Errorf("Job %q should include %q permission in:\n%s", job, perms.requiredWrite, jobSection) + } + for _, forbiddenPermission := range perms.forbidden { + if strings.Contains(jobSection, forbiddenPermission) { + t.Errorf("Job %q should not include %q permission in:\n%s", job, forbiddenPermission, jobSection) + } + } + } + // cleanup-cache-memory job should run on schedule, empty operation, or clean_cache_memories operation cleanupCacheIdx := strings.Index(yaml, "\n cleanup-cache-memory:") if cleanupCacheIdx == -1 { @@ -1088,6 +1146,118 @@ func TestGenerateMaintenanceWorkflow_LabelTriggers_ExplicitTrue(t *testing.T) { } } +func TestGenerateMaintenanceWorkflow_DisabledJobs(t *testing.T) { + workflowDataList := []*WorkflowData{ + { + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{Expires: 48}, + }, + }, + } + + tmpDir := t.TempDir() + trueVal := true + cfg := &RepoConfig{ + Maintenance: &MaintenanceConfig{ + LabelTriggers: &trueVal, + DisabledJobs: []string{ + "close-expired-entities", + "apply_safe_outputs", + "label_disable_agentic_workflow", + "label_apply_safe_outputs", + }, + }, + } + err := GenerateMaintenanceWorkflow(context.Background(), GenerateMaintenanceWorkflowOptions{ + WorkflowDataList: workflowDataList, + WorkflowDir: tmpDir, + Version: "v1.0.0", + ActionMode: ActionModeDev, + ActionTag: "", + RepoConfig: cfg, + RepoSlug: "", + }) + 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, "close-expired-discussions:") { + t.Error("close-expired-discussions job should be omitted when close-expired-entities is disabled in aw.json") + } + if strings.Contains(yaml, "close-expired-issues:") { + t.Error("close-expired-issues job should be omitted when close-expired-entities is disabled in aw.json") + } + if strings.Contains(yaml, "close-expired-pull-requests:") { + t.Error("close-expired-pull-requests job should be omitted when close-expired-entities is disabled in aw.json") + } + if strings.Contains(yaml, "apply_safe_outputs:") { + t.Error("apply_safe_outputs job should be omitted when disabled in aw.json") + } + if strings.Contains(yaml, "label_disable_agentic_workflow:") { + t.Error("label_disable_agentic_workflow job should be omitted when disabled in aw.json") + } + if strings.Contains(yaml, "label_apply_safe_outputs:") { + t.Error("label_apply_safe_outputs job should be omitted when disabled in aw.json") + } + if strings.Contains(yaml, " issues:\n types: [labeled]") { + t.Error("issues:labeled trigger should be omitted when all label-triggered jobs are disabled") + } + + var workflowDoc maintenanceWorkflowDocument + require.NoError(t, yamlv3.Unmarshal(content, &workflowDoc), "generated maintenance workflow should be valid YAML") + require.Equal( + t, + "${{ inputs.run_url }}", + workflowDoc.On.WorkflowCall.Outputs.AppliedRunURL.Value, + "workflow_call applied_run_url should fall back to inputs.run_url", + ) + require.Contains(t, yaml, "workflow_call falls back to inputs.run_url when apply_safe_outputs is disabled; other triggers leave this empty", "generated output description should document the fallback scope") +} + +func TestGenerateMaintenanceWorkflow_DisabledJobs_PartialLabelTrigger(t *testing.T) { + workflowDataList := []*WorkflowData{ + { + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{Expires: 48}, + }, + }, + } + + tmpDir := t.TempDir() + trueVal := true + cfg := &RepoConfig{ + Maintenance: &MaintenanceConfig{ + LabelTriggers: &trueVal, + DisabledJobs: []string{"label_disable_agentic_workflow"}, + }, + } + err := GenerateMaintenanceWorkflow(context.Background(), GenerateMaintenanceWorkflowOptions{ + WorkflowDataList: workflowDataList, + WorkflowDir: tmpDir, + Version: "v1.0.0", + ActionMode: ActionModeDev, + ActionTag: "", + RepoConfig: cfg, + RepoSlug: "", + }) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(tmpDir, "agentics-maintenance.yml")) + require.NoError(t, err) + yaml := string(content) + + require.Contains(t, yaml, " issues:\n types: [labeled]", "issues:labeled trigger should remain when any label-triggered job is still enabled") + require.NotContains(t, yaml, "label_disable_agentic_workflow:", "disabled label job should be omitted") + require.Contains(t, yaml, "label_apply_safe_outputs:", "remaining label-triggered job should still be emitted") +} + func TestGenerateMaintenanceWorkflow_PushTrigger(t *testing.T) { const jobSectionSearchRange = 500 @@ -1178,7 +1348,7 @@ func TestGenerateMaintenanceWorkflow_PushTrigger(t *testing.T) { } }) - t.Run("close-expired-entities and secret-validation exclude push events", func(t *testing.T) { + t.Run("close-expired jobs and secret-validation exclude push events", func(t *testing.T) { tmpDir := t.TempDir() err := GenerateMaintenanceWorkflow(context.Background(), GenerateMaintenanceWorkflowOptions{ WorkflowDataList: workflowDataList, @@ -1199,7 +1369,12 @@ func TestGenerateMaintenanceWorkflow_PushTrigger(t *testing.T) { yaml := string(content) pushExclusionCondition := "github.event_name != 'push'" - scheduleOnlyJobs := []string{"close-expired-entities:", "secret-validation:"} + scheduleOnlyJobs := []string{ + "close-expired-discussions:", + "close-expired-issues:", + "close-expired-pull-requests:", + "secret-validation:", + } for _, job := range scheduleOnlyJobs { jobIdx := strings.Index(yaml, "\n "+job) if jobIdx == -1 { @@ -2211,9 +2386,9 @@ func TestGenerateSideRepoMaintenanceWorkflow(t *testing.T) { if !strings.Contains(contentStr, "GH_AW_GITHUB_TOKEN") { t.Errorf("Side-repo maintenance should use fallback token GH_AW_GITHUB_TOKEN, got content length %d", len(contentStr)) } - // Should NOT include close-expired-entities (no expires). - if strings.Contains(contentStr, "close-expired-entities") { - t.Errorf("Side-repo maintenance should NOT include close-expired-entities when no expires, got content length %d", len(contentStr)) + // Should NOT include close-expired jobs (no expires). + if strings.Contains(contentStr, "close-expired-discussions") || strings.Contains(contentStr, "close-expired-issues") || strings.Contains(contentStr, "close-expired-pull-requests") { + t.Errorf("Side-repo maintenance should NOT include close-expired jobs when no expires, got content length %d", len(contentStr)) } if !strings.Contains(contentStr, "activity_report") { t.Errorf("Side-repo maintenance should include activity_report when no expires, got content length %d", len(contentStr)) diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index ec2e64da04d..7f509c849d2 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -23,6 +23,7 @@ type buildMaintenanceWorkflowYAMLOptions struct { configuredRunsOn RunsOnValue defaultBranch string disableLabelTrigger bool + maintenanceConfig *MaintenanceConfig compileGitHubToken string createCompilePR bool copilotOrgBilling bool // all Copilot workflows use copilot-requests: write (GITHUB_TOKEN); COPILOT_GITHUB_TOKEN is not required @@ -46,10 +47,13 @@ func buildMaintenanceWorkflowYAML( configuredRunsOn := opts.configuredRunsOn defaultBranch := opts.defaultBranch disableLabelTrigger := opts.disableLabelTrigger + maintenanceConfig := opts.maintenanceConfig compileGitHubToken := opts.compileGitHubToken createCompilePR := opts.createCompilePR copilotOrgBilling := opts.copilotOrgBilling maintenanceWorkflowYAMLLog.Printf("Building maintenance workflow YAML: actionMode=%s minExpiresDays=%d cronSchedule=%q defaultBranch=%q disableLabelTrigger=%v createCompilePR=%v copilotOrgBilling=%v", actionMode, minExpiresDays, cronSchedule, defaultBranch, disableLabelTrigger, createCompilePR, copilotOrgBilling) + labelDisableJobEnabled := !disableLabelTrigger && !maintenanceConfig.IsJobDisabled("label_disable_agentic_workflow") + labelApplySafeOutputsJobEnabled := !disableLabelTrigger && !maintenanceConfig.IsJobDisabled("label_apply_safe_outputs") var yaml strings.Builder @@ -88,13 +92,20 @@ on: } // Add label-event trigger only when the label-triggered jobs are enabled - if !disableLabelTrigger { + if labelDisableJobEnabled || labelApplySafeOutputsJobEnabled { maintenanceWorkflowYAMLLog.Print("Adding issues:labeled trigger for label-triggered maintenance jobs") yaml.WriteString(` issues: types: [labeled] `) } + appliedRunURLValue := "${{ jobs.apply_safe_outputs.outputs.run_url }}" + appliedRunURLDescription := "The run URL that safe outputs were applied from" + if maintenanceConfig.IsJobDisabled("apply_safe_outputs") { + appliedRunURLValue = "${{ inputs.run_url }}" + appliedRunURLDescription = "The run URL that safe outputs were applied from (workflow_call falls back to inputs.run_url when apply_safe_outputs is disabled; other triggers leave this empty)" + } + yaml.WriteString(` workflow_dispatch: inputs: operation: @@ -138,78 +149,55 @@ on: description: 'The maintenance operation that was completed (empty when none ran or a scheduled job ran)' value: ${{ jobs.run_operation.outputs.operation || inputs.operation }} applied_run_url: - description: 'The run URL that safe outputs were applied from' - value: ${{ jobs.apply_safe_outputs.outputs.run_url }} + description: '` + appliedRunURLDescription + `' + value: ` + appliedRunURLValue + ` permissions: {} jobs: - close-expired-entities: +`) + + setupActionRef := ResolveSetupActionReference(ctx, actionMode, version, actionTag, resolver) + + writeCloseExpiredJob := func(jobName string, permissionLine string, stepName string, scriptName string) { + yaml.WriteString(` ` + jobName + `: if: ${{ ` + RenderCondition(buildNotForkAndScheduleOnly()) + ` }} runs-on: ` + runsOnValue + ` permissions: - discussions: write - issues: write - pull-requests: write + ` + permissionLine + ` steps: `) - - setupActionRef := ResolveSetupActionReference(ctx, actionMode, version, actionTag, resolver) - - // Add checkout step only in dev/script mode (for local action paths) - if actionMode == ActionModeDev || actionMode == ActionModeScript { - maintenanceWorkflowYAMLLog.Printf("Adding checkout step for close-expired-entities (actionMode=%s)", actionMode) - yaml.WriteString(" - name: Checkout actions folder\n") - yaml.WriteString(" uses: " + getActionPin("actions/checkout") + "\n") - yaml.WriteString(" with:\n") - yaml.WriteString(" sparse-checkout: |\n") - yaml.WriteString(" actions\n") - yaml.WriteString(" clean: false\n") - yaml.WriteString(" persist-credentials: false\n\n") - } - - // Add setup step with the resolved action reference - yaml.WriteString(` - name: Setup Scripts + if actionMode == ActionModeDev || actionMode == ActionModeScript { + maintenanceWorkflowYAMLLog.Printf("Adding checkout step for %s (actionMode=%s)", jobName, actionMode) + yaml.WriteString(" - name: Checkout actions folder\n") + yaml.WriteString(" uses: " + getActionPin("actions/checkout") + "\n") + yaml.WriteString(" with:\n") + yaml.WriteString(" sparse-checkout: |\n") + yaml.WriteString(" actions\n") + yaml.WriteString(" clean: false\n") + yaml.WriteString(" persist-credentials: false\n\n") + } + yaml.WriteString(` - name: Setup Scripts uses: ` + setupActionRef + ` with: destination: ${{ runner.temp }}/gh-aw/actions - - name: Close expired discussions - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - script: | -`) - - // Add the close expired discussions script using require() - yaml.WriteString(` const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/close_expired_discussions.cjs'); - await main(); - - - name: Close expired issues + - name: ` + stepName + ` uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` with: script: | -`) - - // Add the close expired issues script using require() - yaml.WriteString(` const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/close_expired_issues.cjs'); + const { main } = require('${{ runner.temp }}/gh-aw/actions/` + scriptName + `.cjs'); await main(); - - - name: Close expired pull requests - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - script: | `) + } - // Add the close expired pull requests script using require() - yaml.WriteString(` const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/close_expired_pull_requests.cjs'); - await main(); -`) + if !maintenanceConfig.IsJobDisabled("close-expired-entities") { + writeCloseExpiredJob("close-expired-discussions", "discussions: write", "Close expired discussions", "close_expired_discussions") + writeCloseExpiredJob("close-expired-issues", "issues: write", "Close expired issues", "close_expired_issues") + writeCloseExpiredJob("close-expired-pull-requests", "pull-requests: write", "Close expired pull requests", "close_expired_pull_requests") + } // 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, @@ -359,7 +347,8 @@ jobs: `) // Add apply_safe_outputs job for workflow_dispatch with operation == 'safe_outputs' - yaml.WriteString(` + if !maintenanceConfig.IsJobDisabled("apply_safe_outputs") { + yaml.WriteString(` apply_safe_outputs: if: ${{ ` + RenderCondition(buildDispatchOperationCondition("safe_outputs")) + ` }} runs-on: ` + runsOnValue + ` @@ -414,6 +403,7 @@ jobs: GH_AW_RUN_URL: ${{ inputs.run_url }} run: echo "run_url=$GH_AW_RUN_URL" >> "$GITHUB_OUTPUT" `) + } // Add create_labels job for workflow_dispatch with operation == 'create_labels' yaml.WriteString(` @@ -768,10 +758,11 @@ jobs: // markers, disables the corresponding agentic workflow via the GitHub REST API, and posts // a confirmation comment. // Skipped when label_triggers is set to false in aw.json maintenance config. - if !disableLabelTrigger { - maintenanceWorkflowYAMLLog.Print("Adding label-triggered jobs: label_disable_agentic_workflow and label_apply_safe_outputs") - disableLabelCondition := buildLabeledDisableCondition() - yaml.WriteString(` + if labelDisableJobEnabled || labelApplySafeOutputsJobEnabled { + maintenanceWorkflowYAMLLog.Print("Adding label-triggered jobs") + if labelDisableJobEnabled { + disableLabelCondition := buildLabeledDisableCondition() + yaml.WriteString(` label_disable_agentic_workflow: if: ${{ ` + RenderCondition(disableLabelCondition) + ` }} runs-on: ` + runsOnValue + ` @@ -815,11 +806,13 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/disable_agentic_workflow.cjs'); await main(); `) + } // Add label_apply_safe_outputs job triggered by "agentic-workflows:apply-safe-outputs" label on issues. // This job extracts a workflow run URL from the issue body XML comments and re-applies the safe outputs. - applySafeOutputsCondition := buildLabeledApplySafeOutputsCondition() - yaml.WriteString(` + if labelApplySafeOutputsJobEnabled { + applySafeOutputsCondition := buildLabeledApplySafeOutputsCondition() + yaml.WriteString(` label_apply_safe_outputs: if: ${{ ` + RenderCondition(applySafeOutputsCondition) + ` }} runs-on: ` + runsOnValue + ` @@ -867,6 +860,7 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/label_apply_safe_outputs.cjs'); await main(); `) + } } // Add compile-workflows and zizmor-scan jobs only in dev mode diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index 7263f565677..03eebb84276 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -15,6 +15,7 @@ // "runs_on": "custom runner", // string or string[] – runner label(s) for all // "action_failure_issue_expires": 72, // expiration (hours) for conclusion failure issues // "label_triggers": true, // set to true to enable all label-triggered jobs (opt-in) +// "disabled_jobs": ["close-expired-entities"], // optional maintenance jobs to omit // "compile": { // "create_pull_request_github_token": "MY_REPO_TOKEN" // create/update a deduplicated PR instead of an issue // } @@ -99,10 +100,21 @@ type MaintenanceConfig struct { // To opt in, set label_triggers: true in aw.json. LabelTriggers *bool `json:"label_triggers,omitempty"` + // DisabledJobs lists maintenance job IDs that should be omitted from generated + // agentics-maintenance workflows. + DisabledJobs []string `json:"disabled_jobs,omitempty"` + // Compile controls compile-workflows maintenance job behavior. Compile *MaintenanceCompileConfig `json:"compile,omitempty"` } +var validDisabledMaintenanceJobs = map[string]string{ + normalizeMaintenanceJobName("close-expired-entities"): "close-expired-entities", + normalizeMaintenanceJobName("apply_safe_outputs"): "apply_safe_outputs", + normalizeMaintenanceJobName("label_disable_agentic_workflow"): "label_disable_agentic_workflow", + normalizeMaintenanceJobName("label_apply_safe_outputs"): "label_apply_safe_outputs", +} + // IsLabelTriggerEnabled returns true only when label_triggers is explicitly set to true. // The default (nil / omitted) is treated as disabled (false) — opt-in semantics. func (m *MaintenanceConfig) IsLabelTriggerEnabled() bool { @@ -112,6 +124,26 @@ func (m *MaintenanceConfig) IsLabelTriggerEnabled() bool { return *m.LabelTriggers } +func normalizeMaintenanceJobName(name string) string { + normalized := strings.ToLower(strings.TrimSpace(name)) + return strings.ReplaceAll(normalized, "_", "-") +} + +// IsJobDisabled reports whether the provided maintenance job ID is explicitly +// disabled in aw.json. +func (m *MaintenanceConfig) IsJobDisabled(jobName string) bool { + if m == nil || len(m.DisabledJobs) == 0 { + return false + } + normalizedJobName := normalizeMaintenanceJobName(jobName) + for _, disabledJob := range m.DisabledJobs { + if normalizeMaintenanceJobName(disabledJob) == normalizedJobName { + return true + } + } + return false +} + // RepoConfig is the parsed representation of aw.json. type RepoConfig struct { // GHES enables GitHub Enterprise Server compatibility mode. @@ -281,6 +313,22 @@ func validateRepoConfigValues(cfg *RepoConfig) error { } cfg.UTC = normalized } + if cfg.Maintenance != nil { + seenDisabledJobs := map[string]string{} + for _, jobName := range cfg.Maintenance.DisabledJobs { + normalizedJobName := normalizeMaintenanceJobName(jobName) + if normalizedJobName == "" { + return fmt.Errorf("invalid %s: maintenance.disabled_jobs entries must not be blank", RepoConfigFileName) + } + if _, ok := validDisabledMaintenanceJobs[normalizedJobName]; !ok { + return fmt.Errorf("invalid %s: maintenance.disabled_jobs contains unrecognized job %q (valid values: close-expired-entities, apply_safe_outputs, label_disable_agentic_workflow, label_apply_safe_outputs)", RepoConfigFileName, jobName) + } + if previous, exists := seenDisabledJobs[normalizedJobName]; exists { + return fmt.Errorf("invalid %s: maintenance.disabled_jobs contains duplicate entries %q and %q after normalization", RepoConfigFileName, previous, jobName) + } + seenDisabledJobs[normalizedJobName] = jobName + } + } if cfg.Maintenance == nil || cfg.Maintenance.Compile == nil { return nil diff --git a/pkg/workflow/repo_config_test.go b/pkg/workflow/repo_config_test.go index 6740a8bd300..3d308e0e9e2 100644 --- a/pkg/workflow/repo_config_test.go +++ b/pkg/workflow/repo_config_test.go @@ -169,6 +169,54 @@ func TestLoadRepoConfig_LabelTriggers_ExplicitTrue(t *testing.T) { assert.True(t, cfg.Maintenance.IsLabelTriggerEnabled(), "label_triggers: true keeps label-triggered jobs enabled") } +func TestLoadRepoConfig_DisabledJobs(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, `{"maintenance": {"disabled_jobs": ["close-expired-entities", "label-apply-safe-outputs"]}}`) + + cfg, err := LoadRepoConfig(dir) + require.NoError(t, err, "valid aw.json should load without error") + require.NotNil(t, cfg.Maintenance, "maintenance config should be set") + require.Len(t, cfg.Maintenance.DisabledJobs, 2, "disabled_jobs should be parsed") + assert.True(t, cfg.Maintenance.IsJobDisabled("close-expired-entities"), "hyphenated job name should match") + assert.True(t, cfg.Maintenance.IsJobDisabled("label_apply_safe_outputs"), "underscored lookup should match hyphen/underscore equivalently") + assert.False(t, cfg.Maintenance.IsJobDisabled("create_labels"), "unlisted jobs should remain enabled") +} + +func TestLoadRepoConfig_DisabledJobsRejectsInvalidOrDuplicateValues(t *testing.T) { + tests := []struct { + name string + awJSON string + contains string + }{ + { + name: "literal duplicate rejected by schema", + awJSON: `{"maintenance": {"disabled_jobs": ["apply_safe_outputs", "apply_safe_outputs"]}}`, + contains: "disabled_jobs", + }, + { + name: "normalization-equivalent duplicate rejected", + awJSON: `{"maintenance": {"disabled_jobs": ["close-expired-entities", "close_expired_entities"]}}`, + contains: "duplicate entries", + }, + { + name: "unknown job rejected", + awJSON: `{"maintenance": {"disabled_jobs": ["apply_safe_outputz"]}}`, + contains: "unrecognized job", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + writeAWJSON(t, dir, tt.awJSON) + + _, err := LoadRepoConfig(dir) + require.Error(t, err) + assert.ErrorContains(t, err, tt.contains) + }) + } +} + // TestLoadRepoConfig_UnknownProperty tests that unknown properties are rejected. func TestLoadRepoConfig_UnknownProperty(t *testing.T) { dir := t.TempDir()