Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions .github/workflows/agentics-maintenance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
57 changes: 57 additions & 0 deletions docs/adr/43300-scoped-maintenance-job-permissions.md
Original file line number Diff line number Diff line change
@@ -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.*
20 changes: 19 additions & 1 deletion docs/src/content/docs/reference/ephemerals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
}
```
Expand All @@ -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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/grill-with-docs] The docs say job IDs are case-insensitive and _/- equivalent, but they don't list the valid job names a user can put in disabled_jobs. Without an exhaustive list (even a short one), users must read source code to know what strings are accepted — or silently misconfigure without any error feedback.

💡 Suggested addition

Add a table or list of recognized job IDs to the docs block, e.g.:

Job ID Effect when disabled
close-expired-entities Omits all three close-expired jobs
apply_safe_outputs Omits the apply-safe-outputs dispatch job; applied_run_url falls back to inputs.run_url
label_disable_agentic_workflow Omits the label-triggered disable job
label_apply_safe_outputs Omits the label-triggered apply-safe-outputs job

Also note what happens when an unrecognised job ID is provided (currently: silently ignored).

@copilot please address this.


Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs omit the full list of valid job IDs — The description says the field accepts job IDs and explains normalization, but never lists which IDs are actually recognised. Users need to know the valid values. The PR description lists them (close-expired-entities, apply_safe_outputs, label_disable_agentic_workflow, label_apply_safe_outputs), but that information isn't surfaced in the docs.

Please add a table or inline list of all supported job IDs with a brief description of what each one does so users can make an informed choice.

@copilot please address this.

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:**
Expand Down
10 changes: 10 additions & 0 deletions pkg/parser/schemas/repo_config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The schema has uniqueItems: true but there is no test asserting that a duplicate in disabled_jobs is rejected at parse time. If the JSON schema validator silently de-duplicates or if validation is not enforced at runtime, a user could write ["close-expired-entities", "close_expired_entities"] (one hyphen, one underscore) and get two semantically-identical but syntactically-unique entries that both pass schema validation while the normalisation in Go collapses them to one.

💡 Suggested test

Add a case to TestLoadRepoConfig_DisabledJobs (or a sibling) that checks both:

  1. A literal duplicate (same string twice) is rejected by the schema validator.
  2. A normalisation-equivalent pair like ["close-expired-entities", "close_expired_entities"] produces the expected set (one entry) without error — or alternatively is also rejected.

This makes the contract explicit regardless of which validator is in use.

@copilot please address this.

},
"compile": {
"description": "Configuration for the compile-workflows maintenance job.",
"type": "object",
Expand Down
57 changes: 27 additions & 30 deletions pkg/workflow/github_cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
14 changes: 5 additions & 9 deletions pkg/workflow/github_cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
})
}
Expand Down
11 changes: 7 additions & 4 deletions pkg/workflow/maintenance_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) != ""
}
}
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading