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
2 changes: 2 additions & 0 deletions .github/workflows/smoke-copilot.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# ADR-26137: Propagate on.github-token to Activation Checkout and Lock File Hash Check Steps

**Date**: 2026-04-14
**Status**: Draft
**Deciders**: pelikhan, Copilot

---

## Part 1 — Narrative (Human-Friendly)

### Context

The gh-aw compiler generates an activation job that includes several steps using GitHub API credentials: a reaction step, an add-comment step, a label-removal step, a sparse `.github/.agents` checkout step, and a "Check workflow lock file" (hash check) step. The `on.github-token` frontmatter field was already wired to the reaction, comment, and label-removal steps via `resolveActivationToken(data)`. However, the sparse checkout step and the lock file hash check step still used the runner's default `GITHUB_TOKEN`. In cross-org `workflow_call` scenarios—where a caller workflow in one GitHub organization invokes a callee workflow in a different organization—the default `GITHUB_TOKEN` cannot access the callee's repository contents or APIs. This causes the checkout step to fail silently and the hash check API to return HTTP 404, producing a false-positive `ERR_CONFIG: Lock file is outdated or unverifiable` error.

### Decision

We will add a `token string` parameter to `GenerateGitHubFolderCheckoutStep()` and propagate the resolved activation token—obtained via `resolveActivationToken(data)`—to both the sparse checkout step and the "Check workflow lock file" step in the activation job. When the token is empty or equals the literal string `${{ secrets.GITHUB_TOKEN }}`, no `token:` or `github-token:` field is emitted (preserving the default-token behavior for same-org scenarios). This pattern is consistent with the existing approach used for the reaction, comment, and label-removal steps.

### Alternatives Considered

#### Alternative 1: Always emit the token field (even for the default GITHUB_TOKEN)

Emit `token: ${{ secrets.GITHUB_TOKEN }}` unconditionally in the checkout step and `github-token: ${{ secrets.GITHUB_TOKEN }}` in the hash check step. This was considered because it would make the credential source explicit in all generated YAML. It was rejected because it creates unnecessary verbosity in the generated workflow YAML for the common same-org case, and because making the default explicit can mask misconfiguration (if a consumer accidentally sets `on.github-token` to the default secret reference, the emitted YAML would still be indistinguishable from the intended cross-org token).

#### Alternative 2: Create separate checkout step generators for cross-org vs. same-org scenarios

Introduce a new function (e.g., `GenerateCrossOrgGitHubFolderCheckoutStep`) that always emits a `token:` field, and keep the existing function unchanged for same-org use. This was considered because it avoids adding a parameter to an existing API. It was rejected because it duplicates the checkout step generation logic, increasing the maintenance burden, and because callers of `generateCheckoutGitHubFolderForActivation` would still need to decide which variant to call based on the same `resolveActivationToken` output—making the choice implicit rather than explicit.

#### Alternative 3: Resolve the token at the CallerSite inside generateCheckoutGitHubFolderForActivation only (not in hash check)

Apply token propagation only to the checkout step without changing the hash check step. This was considered as a minimal change. It was rejected because it leaves the hash check step vulnerable to the same cross-org 404 failure that the checkout fix addresses. The two steps both require API access to the callee repository, and the fix should be applied consistently.

### Consequences

#### Positive
- Cross-org `workflow_call` scenarios correctly use the configured token for both the sparse checkout and the lock file hash check, eliminating false-positive lock file verification errors.
- Token propagation is now consistent across all activation job steps that access repository content or the GitHub API.
- The convention (empty string or `${{ secrets.GITHUB_TOKEN }}` → no token field emitted) is enforced in a single location in `GenerateGitHubFolderCheckoutStep()`, making it easy to audit.

#### Negative
- `GenerateGitHubFolderCheckoutStep()` is a breaking API change: all existing callers must be updated to pass an explicit token argument (typically `""` for the default). This creates churn in call sites and tests.
- The suppression rule (`token == "" || token == "${{ secrets.GITHUB_TOKEN }}"`) encodes knowledge of a specific GitHub Actions expression string as a special sentinel, which is fragile if the expression format ever changes.

#### Neutral
- The generated smoke lock workflow YAML (`.github/workflows/smoke-copilot.lock.yml`) is updated to explicitly pass the token from the `on.github-token`-equivalent secret, keeping the generated and hand-maintained workflows consistent.
- Existing tests for `GenerateGitHubFolderCheckoutStep` require signature updates (passing `""` for the new token parameter) but their assertions remain unchanged for same-org behavior.

---

## Part 2 — Normative Specification (RFC 2119)

> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).

### Token Parameter in GenerateGitHubFolderCheckoutStep

1. `GenerateGitHubFolderCheckoutStep` **MUST** accept a `token string` parameter after the `ref` parameter and before the `getActionPin` function parameter.
2. Implementations **MUST** emit a `token:` YAML field in the checkout step if and only if `token` is non-empty and not equal to the literal string `${{ secrets.GITHUB_TOKEN }}`.
3. Implementations **MUST NOT** emit a `token:` field when `token` is the empty string `""`.
4. Implementations **MUST NOT** emit a `token:` field when `token` is exactly `${{ secrets.GITHUB_TOKEN }}`.
5. Callers **MUST** pass an explicit token value; passing a non-empty value that is not `${{ secrets.GITHUB_TOKEN }}` **SHALL** result in the token being included in the generated YAML.

### Token Propagation in the Activation Job Compiler

1. The activation job compiler **MUST** call `resolveActivationToken(data)` once per activation job build and reuse the result for all steps that require credential access.
2. The resolved activation token **MUST** be passed to `GenerateGitHubFolderCheckoutStep()` for the `.github/.agents` sparse checkout step.
3. The "Check workflow lock file" step **MUST** emit a `github-token:` field using the resolved activation token if and only if that token is not equal to `${{ secrets.GITHUB_TOKEN }}`.
4. The token propagation pattern for the checkout step and hash check step **SHOULD** remain consistent with the propagation pattern for the reaction, comment, and label-removal steps.

### Conformance

An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance.

---

*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/24376710842) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*
31 changes: 29 additions & 2 deletions pkg/workflow/activation_github_token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,38 @@ func TestActivationGitHubApp(t *testing.T) {
// Both steps should use the same app token
assert.Contains(t, stepsStr, "id: react", "Reaction step should be present")
assert.Contains(t, stepsStr, "id: add-comment", "Add-comment step should be present")
assert.Equal(t, 2, strings.Count(stepsStr, "github-token: ${{ steps.activation-app-token.outputs.token }}"), "Both reaction and comment steps should use app token")
// Both reaction and comment steps should use the same app token, and the hash check step too
assert.Equal(t, 3, strings.Count(stepsStr, "github-token: ${{ steps.activation-app-token.outputs.token }}"), "Reaction, comment, and hash check steps should all use app token")
})
t.Run("app_token_minted_for_hash_check_even_without_reaction_or_comment", func(t *testing.T) {
// Regression test: when ActivationGitHubApp is set but no reaction/comment/label step
// is configured, the mint step must still be generated because the hash check step
// references ${{ steps.activation-app-token.outputs.token }}.
workflowData := &WorkflowData{
Name: "Test Workflow",
// No AIReaction, no StatusComment, no LabelCommand
ActivationGitHubApp: &GitHubAppConfig{
AppID: "${{ vars.APP_ID }}",
PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}",
},
}

job, err := compiler.buildActivationJob(workflowData, false, "", "test.lock.yml")
require.NoError(t, err, "buildActivationJob should succeed")
require.NotNil(t, job)

stepsStr := strings.Join(job.Steps, "")
// The token must be minted so the hash check step can reference it
mintCount := strings.Count(stepsStr, "id: activation-app-token")
assert.Equal(t, 1, mintCount, "Token mint step should appear exactly once even without reaction/comment")

// Hash check step must reference the minted token
assert.Contains(t, stepsStr, "id: check-lock-file", "Hash check step should be present")
assert.Contains(t, stepsStr, "github-token: ${{ steps.activation-app-token.outputs.token }}",
"Hash check step should use the minted app token")
})
}

// TestActivationGitHubTokenExtraction tests extraction of github-token and github-app from frontmatter
func TestActivationGitHubTokenExtraction(t *testing.T) {
compiler := NewCompiler()

Expand Down
6 changes: 3 additions & 3 deletions pkg/workflow/checkout_manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -940,7 +940,7 @@ func TestCrossRepoTargetRepo(t *testing.T) {
cm := NewCheckoutManager(nil)
cm.SetCrossRepoTargetRepo("${{ needs.activation.outputs.target_repo }}")

lines := cm.GenerateGitHubFolderCheckoutStep(cm.GetCrossRepoTargetRepo(), "", GetActionPin)
lines := cm.GenerateGitHubFolderCheckoutStep(cm.GetCrossRepoTargetRepo(), "", "", GetActionPin)
combined := strings.Join(lines, "")

assert.Contains(t, combined, "repository: ${{ needs.activation.outputs.target_repo }}",
Expand Down Expand Up @@ -973,7 +973,7 @@ func TestCrossRepoTargetRef(t *testing.T) {
cm.SetCrossRepoTargetRepo("${{ steps.resolve-host-repo.outputs.target_repo }}")
cm.SetCrossRepoTargetRef("${{ steps.resolve-host-repo.outputs.target_ref }}")

lines := cm.GenerateGitHubFolderCheckoutStep(cm.GetCrossRepoTargetRepo(), cm.GetCrossRepoTargetRef(), GetActionPin)
lines := cm.GenerateGitHubFolderCheckoutStep(cm.GetCrossRepoTargetRepo(), cm.GetCrossRepoTargetRef(), "", GetActionPin)
combined := strings.Join(lines, "")

assert.Contains(t, combined, "repository: ${{ steps.resolve-host-repo.outputs.target_repo }}",
Expand All @@ -985,7 +985,7 @@ func TestCrossRepoTargetRef(t *testing.T) {
t.Run("GenerateGitHubFolderCheckoutStep omits ref: when ref is empty", func(t *testing.T) {
cm := NewCheckoutManager(nil)

lines := cm.GenerateGitHubFolderCheckoutStep("org/repo", "", GetActionPin)
lines := cm.GenerateGitHubFolderCheckoutStep("org/repo", "", "", GetActionPin)
combined := strings.Join(lines, "")

assert.NotContains(t, combined, "ref:", "checkout step should not include ref field when empty")
Expand Down
8 changes: 7 additions & 1 deletion pkg/workflow/checkout_step_generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,14 @@ func (cm *CheckoutManager) GenerateAdditionalCheckoutSteps(getActionPin func(str
// - ref: the branch, tag, or SHA to checkout. May be a literal value or a GitHub Actions
// expression such as "${{ steps.resolve-host-repo.outputs.target_ref }}".
// Pass an empty string to omit the ref field and use the repository's default branch.
// - token: the GitHub token to use for authentication. Pass an empty string or
// "${{ secrets.GITHUB_TOKEN }}" to use the default token (no token: field emitted).
// For cross-org scenarios, pass a PAT or GitHub App token expression.
// - getActionPin: resolves an action reference to a pinned SHA form.
// - extraPaths: additional paths to include in the sparse-checkout beyond .github and .agents.
//
// Returns a slice of YAML lines (each ending with \n).
func (cm *CheckoutManager) GenerateGitHubFolderCheckoutStep(repository, ref string, getActionPin func(string) string, extraPaths ...string) []string {
func (cm *CheckoutManager) GenerateGitHubFolderCheckoutStep(repository, ref, token string, getActionPin func(string) string, extraPaths ...string) []string {
checkoutManagerLog.Printf("Generating .github/.agents folder checkout: repository=%q ref=%q", repository, ref)
var sb strings.Builder

Expand All @@ -106,6 +109,9 @@ func (cm *CheckoutManager) GenerateGitHubFolderCheckoutStep(repository, ref stri
if ref != "" {
fmt.Fprintf(&sb, " ref: %s\n", ref)
}
if token != "" && token != "${{ secrets.GITHUB_TOKEN }}" {
fmt.Fprintf(&sb, " token: %s\n", token)
}
sb.WriteString(" sparse-checkout: |\n")
sb.WriteString(" .github\n")
sb.WriteString(" .agents\n")
Expand Down
25 changes: 22 additions & 3 deletions pkg/workflow/compiler_activation_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,16 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
// Compute filtered label events once and reuse below (permissions + app token scopes)
filteredLabelEvents := FilterLabelCommandEvents(data.LabelCommandEvents)

// needsAppTokenForRepoAccess is true when the GitHub App token is needed for reading
// the callee's repository contents — specifically for the .github checkout step and the
// lock-file hash check step in cross-org workflow_call scenarios.
needsAppTokenForRepoAccess := data.ActivationGitHubApp != nil && !data.StaleCheckDisabled

// Mint a single activation app token upfront if a GitHub App is configured and any
// step in the activation job will need it (reaction, status-comment, or label removal).
// step in the activation job will need it (reaction, status-comment, label removal,
// or repository access for checkout/hash-check).
// This avoids minting multiple tokens.
if data.ActivationGitHubApp != nil && (hasReaction || hasStatusComment || shouldRemoveLabel) {
if data.ActivationGitHubApp != nil && (hasReaction || hasStatusComment || shouldRemoveLabel || needsAppTokenForRepoAccess) {
// Build the combined permissions needed for all activation steps.
// For label removal we only add the scopes required by the enabled events.
appPerms := NewPermissions()
Expand All @@ -132,6 +138,10 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
appPerms.Set(PermissionDiscussions, PermissionWrite)
}
}
if needsAppTokenForRepoAccess {
// contents:read is needed for the .github checkout and the lock-file hash check.
appPerms.Set(PermissionContents, PermissionRead)
}
steps = append(steps, c.buildActivationAppTokenMintStep(data.ActivationGitHubApp, appPerms)...)
// Track whether the token minting succeeded so the conclusion job can surface
// GitHub App authentication errors in the failure issue.
Expand Down Expand Up @@ -214,6 +224,13 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
// as a fallback when the API is unavailable or finds no matching entry.
steps = append(steps, " GH_AW_CONTEXT_WORKFLOW_REF: \"${{ github.workflow_ref }}\"\n")
steps = append(steps, " with:\n")
// Use configured github-token or app-minted token if set; omit to use default GITHUB_TOKEN.
// This is required for cross-org workflow_call where the default GITHUB_TOKEN cannot
// access the callee's repository contents via API.
hashToken := c.resolveActivationToken(data)
if hashToken != "${{ secrets.GITHUB_TOKEN }}" {
Comment on lines +227 to +231
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

resolveActivationToken() can return ${{ steps.activation-app-token.outputs.token }} when data.ActivationGitHubApp is set, but the activation app token mint step is only generated when (hasReaction || hasStatusComment || shouldRemoveLabel) (see earlier in this function). With this change, the lock-file hash check may now emit with.github-token: ${{ steps.activation-app-token.outputs.token }} even when the mint step was not added, causing the workflow to reference a non-existent step ID and fail at runtime. Consider expanding the mint-step condition to include any steps that may use resolveActivationToken (e.g., stale lock-file hash check and the activation job’s sparse checkout), or adjust resolveActivationToken/the callers so the app token is only referenced when the mint step is guaranteed to exist.

Suggested change
// Use configured github-token or app-minted token if set; omit to use default GITHUB_TOKEN.
// This is required for cross-org workflow_call where the default GITHUB_TOKEN cannot
// access the callee's repository contents via API.
hashToken := c.resolveActivationToken(data)
if hashToken != "${{ secrets.GITHUB_TOKEN }}" {
// Use configured github-token when set; omit to use default GITHUB_TOKEN.
// Do not emit the activation app token step output here unless the corresponding
// mint step is guaranteed to exist in this workflow.
// This is required for cross-org workflow_call where the default GITHUB_TOKEN cannot
// access the callee's repository contents via API.
hashToken := c.resolveActivationToken(data)
if hashToken != "${{ secrets.GITHUB_TOKEN }}" && hashToken != "${{ steps.activation-app-token.outputs.token }}" {

Copilot uses AI. Check for mistakes.
steps = append(steps, fmt.Sprintf(" github-token: %s\n", hashToken))
}
steps = append(steps, " script: |\n")
steps = append(steps, generateGitHubScriptWithRequire("check_workflow_timestamp_api.cjs"))
}
Expand Down Expand Up @@ -676,13 +693,15 @@ func (c *Compiler) generateCheckoutGitHubFolderForActivation(data *WorkflowData)
}

cm := NewCheckoutManager(nil)
activationToken := c.resolveActivationToken(data)
if data != nil && hasWorkflowCallTrigger(data.On) && !data.InlinedImports {
compilerActivationJobLog.Print("Adding cross-repo-aware .github checkout for workflow_call trigger")
cm.SetCrossRepoTargetRepo("${{ steps.resolve-host-repo.outputs.target_repo }}")
cm.SetCrossRepoTargetRef("${{ steps.resolve-host-repo.outputs.target_ref }}")
return cm.GenerateGitHubFolderCheckoutStep(
cm.GetCrossRepoTargetRepo(),
cm.GetCrossRepoTargetRef(),
activationToken,
GetActionPin,
extraPaths...,
)
Expand All @@ -692,5 +711,5 @@ func (c *Compiler) generateCheckoutGitHubFolderForActivation(data *WorkflowData)
// This is needed for runtime imports during prompt generation
// sparse-checkout-cone-mode: true ensures subdirectories under .github/ are recursively included
compilerActivationJobLog.Print("Adding .github and .agents sparse checkout in activation job")
return cm.GenerateGitHubFolderCheckoutStep("", "", GetActionPin, extraPaths...)
return cm.GenerateGitHubFolderCheckoutStep("", "", activationToken, GetActionPin, extraPaths...)
}
Loading