From b953e20721811fd6f7849093b6321509a013b47f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 04:23:16 +0000 Subject: [PATCH 01/10] Initial plan From f576d5ce584fe5948a70d5c1be3a36337e6f8193 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 04:37:45 +0000 Subject: [PATCH 02/10] Add maintenance job disable syntax and tests Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/src/content/docs/reference/ephemerals.md | 9 +- pkg/parser/schemas/repo_config_schema.json | 9 ++ pkg/workflow/maintenance_workflow.go | 9 ++ pkg/workflow/maintenance_workflow_test.go | 61 ++++++++++++ pkg/workflow/maintenance_workflow_yaml.go | 93 +++++++++++++------ pkg/workflow/repo_config.go | 25 +++++ pkg/workflow/repo_config_test.go | 13 +++ 7 files changed, 188 insertions(+), 31 deletions(-) diff --git a/docs/src/content/docs/reference/ephemerals.md b/docs/src/content/docs/reference/ephemerals.md index 2f00127ceaa..ebb4b5647a2 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,8 @@ 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. + 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 8c6b4390a61..a936e2e5427 100644 --- a/pkg/parser/schemas/repo_config_schema.json +++ b/pkg/parser/schemas/repo_config_schema.json @@ -87,6 +87,15 @@ "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. Values are case-insensitive; underscores and hyphens are treated equivalently.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, "compile": { "description": "Configuration for the compile-workflows maintenance job.", "type": "object", diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index 51d6cfc30fb..9baed1c2579 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -180,11 +180,19 @@ func GenerateMaintenanceWorkflow(ctx context.Context, opts GenerateMaintenanceWo const defaultRunsOn = "ubuntu-slim" var configuredRunsOn RunsOnValue disableLabelTrigger := true // default: disable label-triggered jobs (opt-in) + disabledMaintenanceJobs := map[string]struct{}{} var compileGitHubTokenSecret string enableCompileCreatePullRequest := false if repoConfig != nil && repoConfig.Maintenance != nil { configuredRunsOn = repoConfig.Maintenance.RunsOn disableLabelTrigger = !repoConfig.Maintenance.IsLabelTriggerEnabled() + for _, jobName := range repoConfig.Maintenance.DisabledJobs { + normalizedJobName := normalizeMaintenanceJobName(jobName) + if normalizedJobName == "" { + continue + } + disabledMaintenanceJobs[normalizedJobName] = struct{}{} + } if repoConfig.Maintenance.Compile != nil { compileGitHubTokenSecret = repoConfig.Maintenance.Compile.CreatePullRequestGitHubToken enableCompileCreatePullRequest = strings.TrimSpace(compileGitHubTokenSecret) != "" @@ -274,6 +282,7 @@ func GenerateMaintenanceWorkflow(ctx context.Context, opts GenerateMaintenanceWo configuredRunsOn: configuredRunsOn, defaultBranch: defaultBranch, disableLabelTrigger: disableLabelTrigger, + disabledJobs: disabledMaintenanceJobs, 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..9f52944db6f 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -1088,6 +1088,67 @@ 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-entities:") { + t.Error("close-expired-entities job should be omitted when 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") + } + if !strings.Contains(yaml, "value: ${{ inputs.run_url }}") { + t.Error("workflow_call applied_run_url should fall back to inputs.run_url when apply_safe_outputs is disabled") + } +} + func TestGenerateMaintenanceWorkflow_PushTrigger(t *testing.T) { const jobSectionSearchRange = 500 diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index ec2e64da04d..76f10fabbca 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -23,11 +23,20 @@ type buildMaintenanceWorkflowYAMLOptions struct { configuredRunsOn RunsOnValue defaultBranch string disableLabelTrigger bool + disabledJobs map[string]struct{} compileGitHubToken string createCompilePR bool copilotOrgBilling bool // all Copilot workflows use copilot-requests: write (GITHUB_TOKEN); COPILOT_GITHUB_TOKEN is not required } +func isMaintenanceJobDisabled(disabledJobs map[string]struct{}, jobName string) bool { + if len(disabledJobs) == 0 { + return false + } + _, exists := disabledJobs[normalizeMaintenanceJobName(jobName)] + return exists +} + // buildMaintenanceWorkflowYAML generates the complete YAML content for the // agentics-maintenance.yml workflow. It is called by GenerateMaintenanceWorkflow // after the cron schedule and setup parameters have been resolved. @@ -46,10 +55,13 @@ func buildMaintenanceWorkflowYAML( configuredRunsOn := opts.configuredRunsOn defaultBranch := opts.defaultBranch disableLabelTrigger := opts.disableLabelTrigger + disabledJobs := opts.disabledJobs 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 && !isMaintenanceJobDisabled(disabledJobs, "label_disable_agentic_workflow") + labelApplySafeOutputsJobEnabled := !disableLabelTrigger && !isMaintenanceJobDisabled(disabledJobs, "label_apply_safe_outputs") var yaml strings.Builder @@ -88,13 +100,22 @@ 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] `) } + operationCompletedValue := "${{ jobs.run_operation.outputs.operation || inputs.operation }}" + if isMaintenanceJobDisabled(disabledJobs, "run_operation") { + operationCompletedValue = "${{ inputs.operation }}" + } + appliedRunURLValue := "${{ jobs.apply_safe_outputs.outputs.run_url }}" + if isMaintenanceJobDisabled(disabledJobs, "apply_safe_outputs") { + appliedRunURLValue = "${{ inputs.run_url }}" + } + yaml.WriteString(` workflow_dispatch: inputs: operation: @@ -136,15 +157,18 @@ on: outputs: operation_completed: description: 'The maintenance operation that was completed (empty when none ran or a scheduled job ran)' - value: ${{ jobs.run_operation.outputs.operation || inputs.operation }} + value: ` + operationCompletedValue + ` applied_run_url: description: 'The run URL that safe outputs were applied from' - value: ${{ jobs.apply_safe_outputs.outputs.run_url }} + value: ` + appliedRunURLValue + ` permissions: {} jobs: - close-expired-entities: +`) + + if !isMaintenanceJobDisabled(disabledJobs, "close-expired-entities") { + yaml.WriteString(` close-expired-entities: if: ${{ ` + RenderCondition(buildNotForkAndScheduleOnly()) + ` }} runs-on: ` + runsOnValue + ` permissions: @@ -153,23 +177,25 @@ jobs: pull-requests: write 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") - } + if !isMaintenanceJobDisabled(disabledJobs, "close-expired-entities") { + // 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 + // Add setup step with the resolved action reference + yaml.WriteString(` - name: Setup Scripts uses: ` + setupActionRef + ` with: destination: ${{ runner.temp }}/gh-aw/actions @@ -180,8 +206,8 @@ jobs: script: | `) - // Add the close expired discussions script using require() - yaml.WriteString(` const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + // 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(); @@ -192,8 +218,8 @@ jobs: script: | `) - // Add the close expired issues script using require() - yaml.WriteString(` const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + // Add the close expired issues 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_issues.cjs'); await main(); @@ -204,12 +230,13 @@ jobs: script: | `) - // Add the close expired pull requests script using require() - yaml.WriteString(` const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + // 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(); `) + } // 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 +386,8 @@ jobs: `) // Add apply_safe_outputs job for workflow_dispatch with operation == 'safe_outputs' - yaml.WriteString(` + if !isMaintenanceJobDisabled(disabledJobs, "apply_safe_outputs") { + yaml.WriteString(` apply_safe_outputs: if: ${{ ` + RenderCondition(buildDispatchOperationCondition("safe_outputs")) + ` }} runs-on: ` + runsOnValue + ` @@ -414,6 +442,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 +797,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 +845,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 +899,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 12694937476..c0cc40f3abd 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,6 +100,10 @@ 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"` } @@ -112,6 +117,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. diff --git a/pkg/workflow/repo_config_test.go b/pkg/workflow/repo_config_test.go index 6740a8bd300..afe6b263dc0 100644 --- a/pkg/workflow/repo_config_test.go +++ b/pkg/workflow/repo_config_test.go @@ -169,6 +169,19 @@ 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") +} + // TestLoadRepoConfig_UnknownProperty tests that unknown properties are rejected. func TestLoadRepoConfig_UnknownProperty(t *testing.T) { dir := t.TempDir() From 5657aeed3ee81d29c2d466a54d5400d3a108168a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 04:41:50 +0000 Subject: [PATCH 03/10] Adjust maintenance output fallback and tighten test assertion Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/maintenance_workflow_test.go | 4 ++-- pkg/workflow/maintenance_workflow_yaml.go | 6 +----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 9f52944db6f..f611b4479fd 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -1144,8 +1144,8 @@ func TestGenerateMaintenanceWorkflow_DisabledJobs(t *testing.T) { if strings.Contains(yaml, " issues:\n types: [labeled]") { t.Error("issues:labeled trigger should be omitted when all label-triggered jobs are disabled") } - if !strings.Contains(yaml, "value: ${{ inputs.run_url }}") { - t.Error("workflow_call applied_run_url should fall back to inputs.run_url when apply_safe_outputs is disabled") + if !strings.Contains(yaml, "applied_run_url:\n description: 'The run URL that safe outputs were applied from'\n value: ${{ inputs.run_url }}") { + t.Error("workflow_call applied_run_url output should fall back to inputs.run_url when apply_safe_outputs is disabled") } } diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index 76f10fabbca..870900e1af8 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -107,10 +107,6 @@ on: `) } - operationCompletedValue := "${{ jobs.run_operation.outputs.operation || inputs.operation }}" - if isMaintenanceJobDisabled(disabledJobs, "run_operation") { - operationCompletedValue = "${{ inputs.operation }}" - } appliedRunURLValue := "${{ jobs.apply_safe_outputs.outputs.run_url }}" if isMaintenanceJobDisabled(disabledJobs, "apply_safe_outputs") { appliedRunURLValue = "${{ inputs.run_url }}" @@ -157,7 +153,7 @@ on: outputs: operation_completed: description: 'The maintenance operation that was completed (empty when none ran or a scheduled job ran)' - value: ` + operationCompletedValue + ` + value: ${{ jobs.run_operation.outputs.operation || inputs.operation }} applied_run_url: description: 'The run URL that safe outputs were applied from' value: ` + appliedRunURLValue + ` From 53a985a8d3e46731b463be9e669c0c88cb5e90d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 05:00:00 +0000 Subject: [PATCH 04/10] Split close-expired maintenance into scoped-permission jobs Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/maintenance_workflow_test.go | 67 ++++++++++++++++++++--- pkg/workflow/maintenance_workflow_yaml.go | 57 +++++-------------- 2 files changed, 73 insertions(+), 51 deletions(-) diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index f611b4479fd..4fd0d62f3d4 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -435,7 +435,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 +456,40 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } } + closeExpiredPermissions := map[string]struct { + requiredWrite string + forbidden []string + }{ + "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+runOpSectionSearchRange] + 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 { @@ -1129,8 +1169,14 @@ func TestGenerateMaintenanceWorkflow_DisabledJobs(t *testing.T) { } yaml := string(content) - if strings.Contains(yaml, "close-expired-entities:") { - t.Error("close-expired-entities job should be omitted when disabled in aw.json") + 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") @@ -1239,7 +1285,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, @@ -1260,7 +1306,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 { @@ -2272,9 +2323,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 870900e1af8..46000fb2b4b 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -163,24 +163,18 @@ permissions: {} jobs: `) - if !isMaintenanceJobDisabled(disabledJobs, "close-expired-entities") { - yaml.WriteString(` 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) - - if !isMaintenanceJobDisabled(disabledJobs, "close-expired-entities") { - // 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) + 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") @@ -189,49 +183,26 @@ jobs: 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 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 !isMaintenanceJobDisabled(disabledJobs, "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 From 7c84d1e2f2670ae3f7dfcc9e0580b45515b7363e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 05:02:19 +0000 Subject: [PATCH 05/10] Refine maintenance permission test assertions Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/maintenance_workflow_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 4fd0d62f3d4..364f1a70ae5 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -456,10 +456,11 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } } - closeExpiredPermissions := map[string]struct { + type permissionAssertion struct { requiredWrite string forbidden []string - }{ + } + closeExpiredPermissions := map[string]permissionAssertion{ "close-expired-discussions:": { requiredWrite: "discussions: write", forbidden: []string{"issues: write", "pull-requests: write"}, @@ -479,7 +480,7 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { t.Errorf("Job %q not found in generated workflow", job) continue } - jobSection := yaml[jobIdx : jobIdx+runOpSectionSearchRange] + 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) } From a581bb212167b7faf5eac118629e2c06fdc607da Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 05:11:25 +0000 Subject: [PATCH 06/10] Add draft ADR-43300: scoped maintenance job permissions Documents the decision to split close-expired-entities into three scoped jobs and add maintenance.disabled_jobs config for selective job omission. --- ...3300-scoped-maintenance-job-permissions.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/adr/43300-scoped-maintenance-job-permissions.md 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.* From 435f29bc8ac5e9041f6bd315f3e2a958333baa62 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 06:47:12 +0000 Subject: [PATCH 07/10] chore: outline PR follow-up plan Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agentics-maintenance.yml | 40 ++++++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) 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 From c90ca269d27ddc467310b3d4ad058d329bcfe46e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 06:57:29 +0000 Subject: [PATCH 08/10] fix maintenance disabled job validation and tests Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/src/content/docs/reference/ephemerals.md | 11 ++++ pkg/parser/schemas/repo_config_schema.json | 5 +- pkg/workflow/maintenance_workflow.go | 20 +++--- pkg/workflow/maintenance_workflow_test.go | 61 ++++++++++++++++++- pkg/workflow/maintenance_workflow_yaml.go | 29 ++++----- pkg/workflow/repo_config.go | 24 ++++++++ pkg/workflow/repo_config_test.go | 37 ++++++++++- 7 files changed, 153 insertions(+), 34 deletions(-) diff --git a/docs/src/content/docs/reference/ephemerals.md b/docs/src/content/docs/reference/ephemerals.md index ebb4b5647a2..7a209e0ad3f 100644 --- a/docs/src/content/docs/reference/ephemerals.md +++ b/docs/src/content/docs/reference/ephemerals.md @@ -145,6 +145,17 @@ The `action_failure_issue_expires` field controls expiration (in hours) for fail 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 a936e2e5427..5547e6812d1 100644 --- a/pkg/parser/schemas/repo_config_schema.json +++ b/pkg/parser/schemas/repo_config_schema.json @@ -88,11 +88,12 @@ "type": "boolean" }, "disabled_jobs": { - "description": "Maintenance workflow job IDs to skip when generating agentics-maintenance.yml. Values are case-insensitive; underscores and hyphens are treated equivalently.", + "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 + "minLength": 1, + "examples": ["close-expired-entities", "apply_safe_outputs", "label_disable_agentic_workflow", "label_apply_safe_outputs"] }, "uniqueItems": true }, diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index 9baed1c2579..8182081f3ee 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -180,21 +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) - disabledMaintenanceJobs := map[string]struct{}{} + var maintenanceConfig *MaintenanceConfig var compileGitHubTokenSecret string enableCompileCreatePullRequest := false if repoConfig != nil && repoConfig.Maintenance != nil { - configuredRunsOn = repoConfig.Maintenance.RunsOn - disableLabelTrigger = !repoConfig.Maintenance.IsLabelTriggerEnabled() - for _, jobName := range repoConfig.Maintenance.DisabledJobs { - normalizedJobName := normalizeMaintenanceJobName(jobName) - if normalizedJobName == "" { - continue - } - disabledMaintenanceJobs[normalizedJobName] = struct{}{} - } - 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) != "" } } @@ -282,7 +276,7 @@ func GenerateMaintenanceWorkflow(ctx context.Context, opts GenerateMaintenanceWo configuredRunsOn: configuredRunsOn, defaultBranch: defaultBranch, disableLabelTrigger: disableLabelTrigger, - disabledJobs: disabledMaintenanceJobs, + 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 364f1a70ae5..6d9fc969b7e 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 @@ -1191,9 +1208,49 @@ func TestGenerateMaintenanceWorkflow_DisabledJobs(t *testing.T) { if strings.Contains(yaml, " issues:\n types: [labeled]") { t.Error("issues:labeled trigger should be omitted when all label-triggered jobs are disabled") } - if !strings.Contains(yaml, "applied_run_url:\n description: 'The run URL that safe outputs were applied from'\n value: ${{ inputs.run_url }}") { - t.Error("workflow_call applied_run_url output should fall back to inputs.run_url when apply_safe_outputs is 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 output should fall back to inputs.run_url when apply_safe_outputs is disabled") + 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) { diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index 46000fb2b4b..6b568320aeb 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -23,20 +23,12 @@ type buildMaintenanceWorkflowYAMLOptions struct { configuredRunsOn RunsOnValue defaultBranch string disableLabelTrigger bool - disabledJobs map[string]struct{} + maintenanceConfig *MaintenanceConfig compileGitHubToken string createCompilePR bool copilotOrgBilling bool // all Copilot workflows use copilot-requests: write (GITHUB_TOKEN); COPILOT_GITHUB_TOKEN is not required } -func isMaintenanceJobDisabled(disabledJobs map[string]struct{}, jobName string) bool { - if len(disabledJobs) == 0 { - return false - } - _, exists := disabledJobs[normalizeMaintenanceJobName(jobName)] - return exists -} - // buildMaintenanceWorkflowYAML generates the complete YAML content for the // agentics-maintenance.yml workflow. It is called by GenerateMaintenanceWorkflow // after the cron schedule and setup parameters have been resolved. @@ -55,13 +47,16 @@ func buildMaintenanceWorkflowYAML( configuredRunsOn := opts.configuredRunsOn defaultBranch := opts.defaultBranch disableLabelTrigger := opts.disableLabelTrigger - disabledJobs := opts.disabledJobs + 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 && !isMaintenanceJobDisabled(disabledJobs, "label_disable_agentic_workflow") - labelApplySafeOutputsJobEnabled := !disableLabelTrigger && !isMaintenanceJobDisabled(disabledJobs, "label_apply_safe_outputs") + isJobDisabled := func(jobName string) bool { + return maintenanceConfig.IsJobDisabled(jobName) + } + labelDisableJobEnabled := !disableLabelTrigger && !isJobDisabled("label_disable_agentic_workflow") + labelApplySafeOutputsJobEnabled := !disableLabelTrigger && !isJobDisabled("label_apply_safe_outputs") var yaml strings.Builder @@ -108,8 +103,10 @@ on: } appliedRunURLValue := "${{ jobs.apply_safe_outputs.outputs.run_url }}" - if isMaintenanceJobDisabled(disabledJobs, "apply_safe_outputs") { + appliedRunURLDescription := "The run URL that safe outputs were applied from" + if 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: @@ -155,7 +152,7 @@ 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' + description: '` + appliedRunURLDescription + `' value: ` + appliedRunURLValue + ` permissions: {} @@ -199,7 +196,7 @@ jobs: `) } - if !isMaintenanceJobDisabled(disabledJobs, "close-expired-entities") { + if !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") @@ -353,7 +350,7 @@ jobs: `) // Add apply_safe_outputs job for workflow_dispatch with operation == 'safe_outputs' - if !isMaintenanceJobDisabled(disabledJobs, "apply_safe_outputs") { + if !isJobDisabled("apply_safe_outputs") { yaml.WriteString(` apply_safe_outputs: if: ${{ ` + RenderCondition(buildDispatchOperationCondition("safe_outputs")) + ` }} diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index c0cc40f3abd..c903ad0f899 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -108,6 +108,13 @@ type MaintenanceConfig struct { 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 { @@ -307,6 +314,23 @@ 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) + } + canonicalJobName, ok := validDisabledMaintenanceJobs[normalizedJobName] + if !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] = canonicalJobName + } + } 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 afe6b263dc0..3d308e0e9e2 100644 --- a/pkg/workflow/repo_config_test.go +++ b/pkg/workflow/repo_config_test.go @@ -171,7 +171,7 @@ func TestLoadRepoConfig_LabelTriggers_ExplicitTrue(t *testing.T) { func TestLoadRepoConfig_DisabledJobs(t *testing.T) { dir := t.TempDir() - writeAWJSON(t, dir, `{"maintenance": {"disabled_jobs": ["close-expired-entities", "label_apply_safe_outputs"]}}`) + 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") @@ -182,6 +182,41 @@ func TestLoadRepoConfig_DisabledJobs(t *testing.T) { 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() From 904e8d7a8792bfbec7a78447049d52b3447a03d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 07:06:41 +0000 Subject: [PATCH 09/10] refine maintenance disabled job follow-up Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/maintenance_workflow_test.go | 7 ++++++- pkg/workflow/maintenance_workflow_yaml.go | 13 +++++-------- pkg/workflow/repo_config.go | 5 ++--- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 6d9fc969b7e..81bf90c97ed 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -1211,7 +1211,12 @@ func TestGenerateMaintenanceWorkflow_DisabledJobs(t *testing.T) { 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 output should fall back to inputs.run_url when apply_safe_outputs is disabled") + 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") } diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index 6b568320aeb..7f509c849d2 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -52,11 +52,8 @@ func buildMaintenanceWorkflowYAML( 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) - isJobDisabled := func(jobName string) bool { - return maintenanceConfig.IsJobDisabled(jobName) - } - labelDisableJobEnabled := !disableLabelTrigger && !isJobDisabled("label_disable_agentic_workflow") - labelApplySafeOutputsJobEnabled := !disableLabelTrigger && !isJobDisabled("label_apply_safe_outputs") + labelDisableJobEnabled := !disableLabelTrigger && !maintenanceConfig.IsJobDisabled("label_disable_agentic_workflow") + labelApplySafeOutputsJobEnabled := !disableLabelTrigger && !maintenanceConfig.IsJobDisabled("label_apply_safe_outputs") var yaml strings.Builder @@ -104,7 +101,7 @@ on: appliedRunURLValue := "${{ jobs.apply_safe_outputs.outputs.run_url }}" appliedRunURLDescription := "The run URL that safe outputs were applied from" - if isJobDisabled("apply_safe_outputs") { + 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)" } @@ -196,7 +193,7 @@ jobs: `) } - if !isJobDisabled("close-expired-entities") { + 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") @@ -350,7 +347,7 @@ jobs: `) // Add apply_safe_outputs job for workflow_dispatch with operation == 'safe_outputs' - if !isJobDisabled("apply_safe_outputs") { + if !maintenanceConfig.IsJobDisabled("apply_safe_outputs") { yaml.WriteString(` apply_safe_outputs: if: ${{ ` + RenderCondition(buildDispatchOperationCondition("safe_outputs")) + ` }} diff --git a/pkg/workflow/repo_config.go b/pkg/workflow/repo_config.go index c903ad0f899..905508c4868 100644 --- a/pkg/workflow/repo_config.go +++ b/pkg/workflow/repo_config.go @@ -321,14 +321,13 @@ func validateRepoConfigValues(cfg *RepoConfig) error { if normalizedJobName == "" { return fmt.Errorf("invalid %s: maintenance.disabled_jobs entries must not be blank", RepoConfigFileName) } - canonicalJobName, ok := validDisabledMaintenanceJobs[normalizedJobName] - if !ok { + 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] = canonicalJobName + seenDisabledJobs[normalizedJobName] = jobName } } From d87438c1912b4dccc649e99af98d8b05444ad183 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 08:13:41 +0000 Subject: [PATCH 10/10] Fix GH CLI env lookup leak in tests Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/github_cli.go | 31 ++++++++++++++++++++++++++++--- pkg/workflow/github_cli_test.go | 6 ++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/pkg/workflow/github_cli.go b/pkg/workflow/github_cli.go index a67462d760a..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,15 +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") == "" { - SetGHHostEnv(cmd, getDefaultGHHost()) + 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. diff --git a/pkg/workflow/github_cli_test.go b/pkg/workflow/github_cli_test.go index ab113d41c57..1317118b878 100644 --- a/pkg/workflow/github_cli_test.go +++ b/pkg/workflow/github_cli_test.go @@ -142,6 +142,9 @@ func TestExecGHUsesConfiguredProcessEnvLookup(t *testing.T) { }) t.Run("does not inject gh token when both tokens are absent", func(t *testing.T) { + t.Setenv("GH_TOKEN", "ambient-gh-token") + t.Setenv("GITHUB_TOKEN", "ambient-github-token") + SetProcessEnvLookup(func(key string) (string, bool) { return "", false }) @@ -154,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") }) }