Skip to content

Commit f78aa11

Browse files
authored
Add checkout.safe-output-github-app support for safe_outputs checkout auth (#44444)
1 parent 56e2b69 commit f78aa11

12 files changed

Lines changed: 454 additions & 27 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# ADR-44444: Per-Checkout GitHub App Auth for safe_outputs Git Operations
2+
3+
**Date**: 2026-07-09
4+
**Status**: Draft
5+
**Deciders**: Unknown
6+
7+
---
8+
9+
### Context
10+
11+
The `safe_outputs` job performs git operations (checkout, push, PR creation) on behalf of agents. Until this change, these operations used either the global `safe-outputs.github-app` setting or the default `GITHUB_TOKEN`. In cross-repo workflows—where the target repository belongs to a different organization than the workflow's home repo—the default token lacks push access, and the global safe-outputs token is coarse-grained (applies uniformly to all safe_outputs operations regardless of target). Agents that check out an external repo (e.g., `OrgB/target-repo`) need a way to supply a GitHub App credential scoped specifically to that checkout's safe_outputs git operations without altering the agent's own checkout authentication.
12+
13+
### Decision
14+
15+
We will add a `checkout.safe-output-github-app` field (with `checkout.safe-outputs-github-app` as a backward-compatible alias) to `CheckoutConfig`. This field carries a `GitHubAppConfig` used exclusively by the `safe_outputs` job when minting tokens for checkout/push operations targeting that repository. Token resolution in `resolvePRCheckoutToken` now checks the checkout manager first—preferring an explicit repository match, then `current: true`, then the default checkout—before falling back to the existing global safe-outputs token chain. The agent job's own checkout authentication is unaffected.
16+
17+
### Alternatives Considered
18+
19+
#### Alternative 1: Use the existing global `safe-outputs.github-app` setting
20+
21+
The global `safe-outputs.github-app` field already supports GitHub App credentials for all safe_outputs operations. Workflows could configure it once and rely on it for cross-repo pushes.
22+
23+
This was not chosen because the global setting is a blunt instrument: it applies to every safe_outputs operation regardless of target repository, making it unsuitable when multiple checkouts target different organizations with different app registrations. It also conflates the safe_outputs credential with the overall workflow credential, which may have wider permissions than needed for a specific checkout target.
24+
25+
#### Alternative 2: Add a `github-app` override field directly on each `safe_outputs` operation config
26+
27+
Each operation (`create-pull-request`, `push-to-pull-request-branch`) could accept its own `github-app` override, keeping auth configuration co-located with the operation that needs it.
28+
29+
This was not chosen because it would require duplicating the app config for every operation that targets the same repository, and it breaks the intuitive mapping between "a checked-out repository" and "the credentials used to push back to it." Anchoring the credential to the checkout config aligns with how `checkout.github-app` already works for agent auth, and lets the token-resolution logic leverage checkout ordering and the `current: true` flag.
30+
31+
### Consequences
32+
33+
#### Positive
34+
- Cross-repo `safe_outputs` operations can use a fine-grained GitHub App token scoped to the exact target repository, without elevating the agent's own checkout credential.
35+
- The resolution precedence (explicit repo match → `current: true` → default checkout) mirrors existing patterns in the checkout manager, keeping the mental model consistent.
36+
- `ignore-if-missing` composes with the new field: when the app installation is absent, the resolver falls back to the global safe-outputs token chain transparently.
37+
38+
#### Negative
39+
- Each checkout entry can now carry two separate GitHub App configs (`github-app` for agent auth, `safe-output-github-app` for safe_outputs auth), increasing configuration surface area and the potential for user confusion about which credential applies when.
40+
- The backward-compatible `safe-outputs-github-app` alias adds parser complexity and a permanent dual-key code path that must be maintained.
41+
42+
#### Neutral
43+
- The `resolvePRCheckoutToken` function signature gains a `*CheckoutManager` parameter; all existing callers that passed `nil` previously now pass an empty `NewCheckoutManager(nil)`, preserving existing behavior.
44+
- Tests covering parser, checkout manager, step generator, and token resolution were added alongside the feature code, establishing coverage baselines for the new code paths.
45+
46+
---
47+
48+
*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*

docs/src/content/docs/reference/checkout.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ checkout:
5151
| `path` | string | Path within `GITHUB_WORKSPACE` to place the checkout. Defaults to workspace root. |
5252
| `github-token` | string | Token for authentication. Use `${{ secrets.MY_TOKEN }}` syntax. |
5353
| `github-app` | object | GitHub App credentials (`client-id` or `app-id` (deprecated), `private-key`, optional `owner`, `repositories`). Mutually exclusive with `github-token`. `app` is a deprecated alias for the field name. Run `gh aw fix` to auto-migrate `app-id` to `client-id`. |
54-
| `safe-output-github-app` | object | Optional per-checkout GitHub App credentials used exclusively for safe_outputs git operations on this checkout target (`client-id` or `app-id` (deprecated), `private-key`, optional `owner`, `repositories`). Does not change agent/activation checkout auth. See [Cross-Organization safe_outputs Authentication](#cross-organization-safe_outputs-authentication-safe-output-github-app) for cross-org usage. |
54+
| `safe-outputs-github-app` | object | Optional per-checkout GitHub App credentials used exclusively for safe_outputs git operations on this checkout target (`client-id` or `app-id` (deprecated), `private-key`, optional `owner`, `repositories`). Does not change agent/activation checkout auth. See [Cross-Organization safe_outputs Authentication](#cross-organization-safe_outputs-authentication-safe-outputs-github-app) for cross-org usage. |
5555
| `fetch-depth` | integer | Commits to fetch. `0` = full history, `1` = shallow clone (default). |
5656
| `fetch` | string \| string[] | Additional Git refs to fetch after checkout. See [Fetching Additional Refs](#fetching-additional-refs). |
5757
| `sparse-checkout` | string | Newline-separated patterns for sparse checkout (e.g., `.github/\nsrc/`). |
@@ -154,11 +154,11 @@ checkout:
154154
force-clean-git-credentials: true
155155
```
156156

157-
## Cross-Organization safe_outputs Authentication (`safe-output-github-app`)
157+
## Cross-Organization safe_outputs Authentication (`safe-outputs-github-app`)
158158

159159
By default, the safe_outputs job uses `GITHUB_TOKEN` to check out repositories — `safe-outputs.github-app` and `safe-outputs.github-token` are **not** used for checkout and only govern PR/push API operations. This design prevents cross-org token confusion when the safe-outputs app is scoped to a target organization that differs from the workflow repository's organization.
160160

161-
For workflows that target a different organization, use `safe-output-github-app` on the relevant checkout entry to supply a checkout token for the safe_outputs job:
161+
For workflows that target a different organization, use `safe-outputs-github-app` on the relevant checkout entry to supply a checkout token for the safe_outputs job:
162162

163163
```yaml wrap
164164
checkout:
@@ -167,7 +167,7 @@ checkout:
167167
client-id: ${{ vars.APP_CLIENT_ID }}
168168
private-key: ${{ secrets.APP_PRIVATE_KEY }}
169169
owner: OrgB
170-
safe-output-github-app:
170+
safe-outputs-github-app:
171171
client-id: ${{ vars.APP_CLIENT_ID }}
172172
private-key: ${{ secrets.APP_PRIVATE_KEY }}
173173
owner: OrgB
@@ -184,7 +184,7 @@ safe-outputs:
184184

185185
In this configuration:
186186
- The agent job checks out `OrgB/target-repo` using the app token scoped to `OrgB`.
187-
- The safe_outputs job checks out `OrgB/target-repo` using the `safe-output-github-app` token.
187+
- The safe_outputs job checks out `OrgB/target-repo` using the `safe-outputs-github-app` token.
188188
- The safe_outputs job checks out the **workflow repository** using `GITHUB_TOKEN` (the default), not the `OrgB`-scoped app token — avoiding the cross-org authentication failure.
189189
- `safe-outputs.github-app` is used only for PR creation and push operations, not for checkout.
190190

docs/src/content/docs/specs/checkout-behavior-specification.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,11 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S
8282
### 3.1 Checkout Entry Parsing
8383

8484
`checkout:` MUST accept either a single object or an array of objects.
85-
Each entry MAY define: `repository`, `ref`, `path`, `github-token` (or legacy `token`), `github-app` (or deprecated alias `app`), `safe-output-github-app`, `fetch-depth`, `fetch`, `sparse-checkout`, `submodules`, `lfs`, `current`, `wiki`, and `force-clean-git-credentials`.
85+
Each entry MAY define: `repository`, `ref`, `path`, `github-token` (or legacy `token`), `github-app` (or deprecated alias `app`), `safe-outputs-github-app`, `fetch-depth`, `fetch`, `sparse-checkout`, `submodules`, `lfs`, `current`, `wiki`, and `force-clean-git-credentials`.
8686

8787
`github-token` and `github-app` MUST be mutually exclusive per entry.
8888

89-
`safe-output-github-app` applies only to safe_outputs git auth/token resolution. It MUST NOT change agent/activation checkout authentication behavior.
89+
`safe-outputs-github-app` applies only to safe_outputs git auth/token resolution. It MUST NOT change agent/activation checkout authentication behavior.
9090

9191
### 3.2 Entry Merge Rules
9292

@@ -95,7 +95,7 @@ Entries with the same `(repository, path, wiki)` key MUST merge with these rules
9595
- `fetch-depth`: deepest wins (`0` wins over all)
9696
- `ref`: first non-empty wins
9797
- auth (`github-token` vs `github-app`): first auth wins
98-
- safe_outputs auth (`safe-output-github-app`): first non-empty wins
98+
- safe_outputs auth (`safe-outputs-github-app`): first non-empty wins
9999
- sparse patterns: union
100100
- fetch refs: union
101101
- `lfs`: OR
@@ -144,7 +144,7 @@ Activation token precedence MUST be:
144144

145145
`resolvePRCheckoutToken` precedence MUST be:
146146

147-
1. Checkout target `safe-output-github-app` minted token (with fallback chain when `ignore-if-missing: true`)
147+
1. Checkout target `safe-outputs-github-app` minted token (with fallback chain when `ignore-if-missing: true`)
148148
2. `${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}`
149149

150150
When safe_outputs checkout retention is enabled, checkouts without explicit entry tokens MUST persist the resolved PR checkout token so local git credentials match push/fetch token usage.
@@ -236,8 +236,8 @@ Effective side-repo token precedence MUST be:
236236
- **T-CHK-008**: Trial mode repository/token override behavior
237237
- **T-CHK-009**: Side-repo target extraction and auth precedence
238238
- **T-CHK-010**: `force-clean-git-credentials` cleanup covers `.git/modules/**/config`
239-
- **T-CHK-011**: `checkout.safe-output-github-app` is the sole supported safe_outputs auth override per checkout entry
240-
- **T-CHK-012**: safe_outputs checkout token MUST NOT use `safe-outputs.github-app` or `safe-outputs.github-token`; only `safe-output-github-app` (per entry) or `GITHUB_TOKEN` are permitted
239+
- **T-CHK-011**: `checkout.safe-outputs-github-app` is the sole supported safe_outputs auth override per checkout entry
240+
- **T-CHK-012**: safe_outputs checkout token MUST NOT use `safe-outputs.github-app` or `safe-outputs.github-token`; only `safe-outputs-github-app` (per entry) or `GITHUB_TOKEN` are permitted
241241
- **T-CHK-013**: Checkout-manifest generation includes safe_outputs auth metadata without persisting resolved tokens
242242

243243
### 7.2 Compliance Checklist

pkg/parser/schemas/main_workflow_schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13170,6 +13170,10 @@
1317013170
"$ref": "#/$defs/github_app",
1317113171
"description": "GitHub App authentication. Mints a short-lived installation access token via actions/create-github-app-token. Mutually exclusive with github-token."
1317213172
},
13173+
"safe-outputs-github-app": {
13174+
"$ref": "#/$defs/github_app",
13175+
"description": "GitHub App authentication used only by safe_outputs checkout/fetch/push operations for this checkout target. Does not change activation/agent checkout authentication."
13176+
},
1317313177
"current": {
1317413178
"type": "boolean",
1317513179
"description": "Marks this checkout as the logical current repository for the workflow. When set to true, the AI agent will treat this repository as its primary working target. Only one checkout may have current set to true. Useful for central-repo workflows targeting a different repository."

pkg/workflow/checkout_config_parser.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,29 @@ func checkoutConfigFromMap(m map[string]any) (*CheckoutConfig, error) {
138138
}
139139
}
140140

141+
// Parse app configuration for safe_outputs-only GitHub App authentication.
142+
parseSafeOutputAppConfig := func(fieldName string, value any) (*GitHubAppConfig, error) {
143+
appMap, ok := value.(map[string]any)
144+
if !ok {
145+
return nil, fmt.Errorf("checkout.%s must be an object", fieldName)
146+
}
147+
appConfig := parseAppConfig(appMap)
148+
if appConfig.AppID == "" || appConfig.PrivateKey == "" {
149+
return nil, fmt.Errorf("checkout.%s requires both client-id (or app-id) and private-key", fieldName)
150+
}
151+
return appConfig, nil
152+
}
153+
if v, ok := m["safe-outputs-github-app"]; ok {
154+
appConfig, err := parseSafeOutputAppConfig("safe-outputs-github-app", v)
155+
if err != nil {
156+
return nil, err
157+
}
158+
cfg.SafeOutputGitHubApp = appConfig
159+
}
160+
if _, ok := m["safe-output-github-app"]; ok {
161+
return nil, errors.New("checkout.safe-output-github-app is not supported; use checkout.safe-outputs-github-app")
162+
}
163+
141164
// Validate mutual exclusivity of github-token and github-app
142165
if cfg.GitHubToken != "" && cfg.GitHubApp != nil {
143166
checkoutManagerLog.Print("Rejecting checkout config: github-token and github-app are mutually exclusive")

pkg/workflow/checkout_manager.go

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package workflow
22

33
import (
4+
"fmt"
45
"strings"
56

67
"github.com/github/gh-aw/pkg/logger"
@@ -63,6 +64,11 @@ type CheckoutConfig struct {
6364
// Mutually exclusive with GitHubToken.
6465
GitHubApp *GitHubAppConfig `json:"github-app,omitempty"`
6566

67+
// SafeOutputGitHubApp configures GitHub App-based authentication used only by
68+
// safe_outputs git checkout/fetch/push operations for this checkout target.
69+
// This does not change activation/agent checkout authentication.
70+
SafeOutputGitHubApp *GitHubAppConfig `json:"safe-outputs-github-app,omitempty"`
71+
6672
// FetchDepth controls the number of commits to fetch.
6773
// 0 fetches all history (full clone). 1 is a shallow clone (default).
6874
FetchDepth *int `json:"fetch-depth,omitempty"`
@@ -127,6 +133,7 @@ type resolvedCheckout struct {
127133
ref string // last non-empty ref wins
128134
token string // last non-empty github-token wins
129135
githubApp *GitHubAppConfig // GitHub App config (first non-nil wins)
136+
safeOutputApp *GitHubAppConfig // safe_outputs-only GitHub App config (first non-nil wins)
130137
fetchDepth *int // nil means use default (1)
131138
sparsePatterns []string // merged sparse-checkout patterns
132139
submodules string
@@ -291,6 +298,9 @@ func (cm *CheckoutManager) add(cfg *CheckoutConfig) {
291298
if cfg.GitHubApp != nil && entry.githubApp == nil && entry.token == "" {
292299
entry.githubApp = cfg.GitHubApp // first-seen auth wins (mutually exclusive with github-token)
293300
}
301+
if cfg.SafeOutputGitHubApp != nil && entry.safeOutputApp == nil {
302+
entry.safeOutputApp = cfg.SafeOutputGitHubApp // first-seen safe_outputs auth wins
303+
}
294304
if cfg.SparseCheckout != "" {
295305
entry.sparsePatterns = mergeSparsePatterns(entry.sparsePatterns, cfg.SparseCheckout)
296306
}
@@ -312,15 +322,16 @@ func (cm *CheckoutManager) add(cfg *CheckoutConfig) {
312322
checkoutManagerLog.Printf("Merged checkout for path=%q repository=%q", key.path, key.repository)
313323
} else {
314324
entry := &resolvedCheckout{
315-
key: key,
316-
ref: cfg.Ref,
317-
token: cfg.GitHubToken,
318-
githubApp: cfg.GitHubApp,
319-
fetchDepth: cfg.FetchDepth,
320-
submodules: cfg.Submodules,
321-
lfs: cfg.LFS,
322-
current: cfg.Current,
323-
cleanCreds: cfg.CleanGitCredentials,
325+
key: key,
326+
ref: cfg.Ref,
327+
token: cfg.GitHubToken,
328+
githubApp: cfg.GitHubApp,
329+
safeOutputApp: cfg.SafeOutputGitHubApp,
330+
fetchDepth: cfg.FetchDepth,
331+
submodules: cfg.Submodules,
332+
lfs: cfg.LFS,
333+
current: cfg.Current,
334+
cleanCreds: cfg.CleanGitCredentials,
324335
}
325336
if cfg.SparseCheckout != "" {
326337
entry.sparsePatterns = mergeSparsePatterns(nil, cfg.SparseCheckout)
@@ -362,6 +373,68 @@ func (cm *CheckoutManager) HasAppAuth() bool {
362373
return false
363374
}
364375

376+
// HasSafeOutputAppAuth returns true if any checkout entry uses safe_outputs-only
377+
// GitHub App authentication.
378+
func (cm *CheckoutManager) HasSafeOutputAppAuth() bool {
379+
for _, entry := range cm.ordered {
380+
if entry.safeOutputApp != nil {
381+
return true
382+
}
383+
}
384+
return false
385+
}
386+
387+
// ResolveSafeOutputCheckoutTokenExpression returns a safe_outputs checkout token
388+
// expression derived from checkout.safe-outputs-github-app for the target repo.
389+
// The selected checkout precedence is:
390+
// 1. explicit matching checkout.repository == targetRepo (when targetRepo is non-empty)
391+
// 2. checkout marked current: true
392+
// 3. default checkout override (workspace root)
393+
func (cm *CheckoutManager) ResolveSafeOutputCheckoutTokenExpression(targetRepo string) (string, bool) {
394+
findSafeOutputAppCheckoutIndex := func() int {
395+
targetRepo = strings.TrimSpace(targetRepo)
396+
if targetRepo != "" && targetRepo != "*" {
397+
for idx, entry := range cm.ordered {
398+
if entry.key.wiki || entry.safeOutputApp == nil {
399+
continue
400+
}
401+
if entry.key.repository == targetRepo {
402+
return idx
403+
}
404+
}
405+
}
406+
407+
for idx, entry := range cm.ordered {
408+
if entry.key.wiki || entry.safeOutputApp == nil {
409+
continue
410+
}
411+
if entry.current {
412+
return idx
413+
}
414+
}
415+
416+
if override := cm.GetDefaultCheckoutOverride(); override != nil && !override.key.wiki && override.safeOutputApp != nil {
417+
if idx, ok := cm.index[override.key]; ok {
418+
return idx
419+
}
420+
}
421+
return -1
422+
}
423+
424+
idx := findSafeOutputAppCheckoutIndex()
425+
if idx < 0 {
426+
return "", false
427+
}
428+
429+
//nolint:gosec // G101: False positive - this is a GitHub Actions expression template placeholder, not a hardcoded credential
430+
token := fmt.Sprintf("${{ steps.checkout-safe-output-app-token-%d.outputs.token }}", idx)
431+
app := cm.ordered[idx].safeOutputApp
432+
if app != nil && app.shouldIgnoreMissingKey() {
433+
token = combineTokenExpressions(token, getEffectiveSafeOutputGitHubToken(""))
434+
}
435+
return token, true
436+
}
437+
365438
// resolveCheckoutPermissions determines the permissions used when minting checkout
366439
// GitHub App tokens. Both the agent job and the safe_outputs job resolve them the same
367440
// way: explicit cached permissions take precedence, then parsed frontmatter permissions,

0 commit comments

Comments
 (0)