From b982655a70b70614bc32ba34f7e40a0d4efd1df6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 01:21:17 +0000 Subject: [PATCH 1/4] Initial plan From f7a7fa599619fe370c3e46f98f67ed0bfffad7df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 01:42:33 +0000 Subject: [PATCH 2/4] fix: propagate on.github-token to checkout and hash check steps in activation job - Add token parameter to GenerateGitHubFolderCheckoutStep() and emit token: field when it's not the default GITHUB_TOKEN - Pass resolveActivationToken(data) to the checkout step in generateCheckoutGitHubFolderForActivation() - Add github-token: to Check workflow lock file step using resolveActivationToken(data) - Update all test callers with the new token parameter - Add tests for token propagation behavior - Update activation_github_token_test.go to reflect that hash check step also uses the app token (3 uses instead of 2) Fixes cross-org workflow_call failures where the default GITHUB_TOKEN cannot access the callee's repository. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/3fee77dc-cca2-4484-a576-2b9daf779b7d Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/smoke-copilot.lock.yml | 2 + pkg/workflow/activation_github_token_test.go | 3 +- pkg/workflow/checkout_manager_test.go | 6 +- pkg/workflow/checkout_step_generator.go | 8 +- pkg/workflow/compiler_activation_job.go | 11 +- pkg/workflow/compiler_activation_job_test.go | 176 ++++++++++++++++++- 6 files changed, 198 insertions(+), 8 deletions(-) diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index 84d1fb09092..40d84b6c700 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -172,6 +172,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} sparse-checkout: | .github .agents @@ -185,6 +186,7 @@ jobs: GH_AW_WORKFLOW_FILE: "smoke-copilot.lock.yml" GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}" with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io, getOctokit); diff --git a/pkg/workflow/activation_github_token_test.go b/pkg/workflow/activation_github_token_test.go index c07fea2aecc..3dfd8c32e22 100644 --- a/pkg/workflow/activation_github_token_test.go +++ b/pkg/workflow/activation_github_token_test.go @@ -172,7 +172,8 @@ 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") }) } diff --git a/pkg/workflow/checkout_manager_test.go b/pkg/workflow/checkout_manager_test.go index 1b084c41c61..d2119fd5bd8 100644 --- a/pkg/workflow/checkout_manager_test.go +++ b/pkg/workflow/checkout_manager_test.go @@ -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 }}", @@ -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 }}", @@ -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") diff --git a/pkg/workflow/checkout_step_generator.go b/pkg/workflow/checkout_step_generator.go index 1fb5338e271..4ab50032894 100644 --- a/pkg/workflow/checkout_step_generator.go +++ b/pkg/workflow/checkout_step_generator.go @@ -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 @@ -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") diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index fcf0e385d60..b1912e5a084 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -214,6 +214,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 }}" { + steps = append(steps, fmt.Sprintf(" github-token: %s\n", hashToken)) + } steps = append(steps, " script: |\n") steps = append(steps, generateGitHubScriptWithRequire("check_workflow_timestamp_api.cjs")) } @@ -676,6 +683,7 @@ 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 }}") @@ -683,6 +691,7 @@ func (c *Compiler) generateCheckoutGitHubFolderForActivation(data *WorkflowData) return cm.GenerateGitHubFolderCheckoutStep( cm.GetCrossRepoTargetRepo(), cm.GetCrossRepoTargetRef(), + activationToken, GetActionPin, extraPaths..., ) @@ -692,5 +701,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...) } diff --git a/pkg/workflow/compiler_activation_job_test.go b/pkg/workflow/compiler_activation_job_test.go index 22608614daa..457061d2d11 100644 --- a/pkg/workflow/compiler_activation_job_test.go +++ b/pkg/workflow/compiler_activation_job_test.go @@ -198,7 +198,7 @@ func TestGenerateGitHubFolderCheckoutStep(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := NewCheckoutManager(nil).GenerateGitHubFolderCheckoutStep(tt.repository, "", GetActionPin) + result := NewCheckoutManager(nil).GenerateGitHubFolderCheckoutStep(tt.repository, "", "", GetActionPin) require.NotEmpty(t, result, "should return at least one YAML line") @@ -615,7 +615,7 @@ func TestGenerateCheckoutGitHubFolderForActivation_ActionsModeSetupPath(t *testi // TestGenerateGitHubFolderCheckoutStep_ExtraPaths verifies that extraPaths are // correctly appended to the sparse-checkout list. func TestGenerateGitHubFolderCheckoutStep_ExtraPaths(t *testing.T) { - result := NewCheckoutManager(nil).GenerateGitHubFolderCheckoutStep("", "", GetActionPin, "actions/setup", "custom/path") + result := NewCheckoutManager(nil).GenerateGitHubFolderCheckoutStep("", "", "", GetActionPin, "actions/setup", "custom/path") combined := strings.Join(result, "") assert.Contains(t, combined, ".github", "should include .github") @@ -623,3 +623,175 @@ func TestGenerateGitHubFolderCheckoutStep_ExtraPaths(t *testing.T) { assert.Contains(t, combined, "actions/setup", "should include extra path actions/setup") assert.Contains(t, combined, "custom/path", "should include extra path custom/path") } + +// TestGenerateGitHubFolderCheckoutStep_Token verifies that the token: field is emitted +// only for non-default tokens, supporting cross-org workflow_call scenarios. +func TestGenerateGitHubFolderCheckoutStep_Token(t *testing.T) { + tests := []struct { + name string + token string + wantToken bool + wantValue string + }{ + { + name: "empty token - no token field", + token: "", + wantToken: false, + }, + { + name: "default GITHUB_TOKEN - no token field emitted", + token: "${{ secrets.GITHUB_TOKEN }}", + wantToken: false, + }, + { + name: "custom PAT secret - token field emitted", + token: "${{ secrets.CROSS_ORG_TOKEN }}", + wantToken: true, + wantValue: "${{ secrets.CROSS_ORG_TOKEN }}", + }, + { + name: "GitHub App minted token - token field emitted", + token: "${{ steps.activation-app-token.outputs.token }}", + wantToken: true, + wantValue: "${{ steps.activation-app-token.outputs.token }}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NewCheckoutManager(nil).GenerateGitHubFolderCheckoutStep("org/repo", "", tt.token, GetActionPin) + combined := strings.Join(result, "") + + if tt.wantToken { + assert.Contains(t, combined, "token: "+tt.wantValue, + "should include token field with correct value") + } else { + assert.NotContains(t, combined, "token:", + "should not include token field for default or empty token") + } + }) + } +} + +// TestCheckoutTokenPropagatedToActivation verifies that the on.github-token frontmatter field +// is propagated to the activation job's .github checkout step for cross-org workflow_call support. +func TestCheckoutTokenPropagatedToActivation(t *testing.T) { + tests := []struct { + name string + activationToken string + onSection string + wantTokenInStep bool + wantTokenValue string + }{ + { + name: "custom token with workflow_call - token emitted in checkout", + activationToken: "${{ secrets.CROSS_ORG_TOKEN }}", + onSection: `"on": + workflow_call:`, + wantTokenInStep: true, + wantTokenValue: "${{ secrets.CROSS_ORG_TOKEN }}", + }, + { + name: "default GITHUB_TOKEN - no token field in checkout", + activationToken: "", + onSection: `"on": + workflow_call:`, + wantTokenInStep: false, + }, + { + name: "custom token without workflow_call - token emitted in checkout", + activationToken: "${{ secrets.CROSS_ORG_TOKEN }}", + onSection: `"on": + issues: + types: [opened]`, + wantTokenInStep: true, + wantTokenValue: "${{ secrets.CROSS_ORG_TOKEN }}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewCompilerWithVersion("dev") + c.SetActionMode(ActionModeDev) + + data := &WorkflowData{ + On: tt.onSection, + ActivationGitHubToken: tt.activationToken, + } + + result := c.generateCheckoutGitHubFolderForActivation(data) + combined := strings.Join(result, "") + + if tt.wantTokenInStep { + assert.Contains(t, combined, "token: "+tt.wantTokenValue, + "checkout step should include token field for cross-org support") + } else { + assert.NotContains(t, combined, "token:", + "checkout step should not include token field when using default GITHUB_TOKEN") + } + }) + } +} + +// TestHashCheckTokenPropagation verifies that the on.github-token frontmatter field +// is propagated to the "Check workflow lock file" step for cross-org workflow_call support. +func TestHashCheckTokenPropagation(t *testing.T) { + tests := []struct { + name string + activationToken string + wantTokenInStep bool + wantTokenValue string + }{ + { + name: "custom token - github-token emitted in hash check step", + activationToken: "${{ secrets.CROSS_ORG_TOKEN }}", + wantTokenInStep: true, + wantTokenValue: "${{ secrets.CROSS_ORG_TOKEN }}", + }, + { + name: "default GITHUB_TOKEN - no github-token field in hash check step", + activationToken: "", + wantTokenInStep: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompilerWithVersion("dev") + compiler.SetActionMode(ActionModeDev) + + data := &WorkflowData{ + Name: "test-workflow", + On: `"on": + workflow_call:`, + ActivationGitHubToken: tt.activationToken, + AI: "copilot", + } + + job, err := compiler.buildActivationJob(data, false, "", "test.lock.yml") + require.NoError(t, err, "buildActivationJob should succeed") + require.NotNil(t, job, "activation job should not be nil") + + // Find the check-lock-file step in the job steps + combined := strings.Join(job.Steps, "") + // Extract the check-lock-file step region + lockFileIdx := strings.Index(combined, "id: check-lock-file") + require.NotEqual(t, -1, lockFileIdx, "check-lock-file step should be present") + + // Get a window around the lock file step to check for github-token + lockFileSection := combined[lockFileIdx:] + nextStepIdx := strings.Index(lockFileSection[10:], " - name:") + if nextStepIdx != -1 { + lockFileSection = lockFileSection[:nextStepIdx+10] + } + + if tt.wantTokenInStep { + assert.Contains(t, lockFileSection, "github-token: "+tt.wantTokenValue, + "hash check step should include github-token field for cross-org support") + } else { + assert.NotContains(t, lockFileSection, "github-token:", + "hash check step should not include github-token field when using default GITHUB_TOKEN") + } + }) + } +} From b27520e402e7c81e6d08f11eaf4ea85162bff752 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 02:01:07 +0000 Subject: [PATCH 3/4] docs(adr): add draft ADR-26137 for github-token propagation to activation checkout and hash steps Co-Authored-By: Claude Sonnet 4.6 --- ...n-to-activation-checkout-and-hash-steps.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/adr/26137-propagate-github-token-to-activation-checkout-and-hash-steps.md diff --git a/docs/adr/26137-propagate-github-token-to-activation-checkout-and-hash-steps.md b/docs/adr/26137-propagate-github-token-to-activation-checkout-and-hash-steps.md new file mode 100644 index 00000000000..061f5dd407c --- /dev/null +++ b/docs/adr/26137-propagate-github-token-to-activation-checkout-and-hash-steps.md @@ -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.* From 53fa484aacb43287cf9a1dfcdae558dfe1e36f4f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 03:28:01 +0000 Subject: [PATCH 4/4] fix: always mint activation app token when hash check step needs it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When ActivationGitHubApp is set but no reaction/comment/label step is configured, the mint step was not generated — but the hash check step still emitted github-token: ${{ steps.activation-app-token.outputs.token }}, causing a runtime reference to a non-existent step ID. Fix: introduce needsAppTokenForRepoAccess flag (true when an app is configured and stale-check is enabled) and include it in the mint condition. Also adds contents:read to the app permissions when the token is needed for repo access. Adds regression test: app_token_minted_for_hash_check_even_without_reaction_or_comment Agent-Logs-Url: https://github.com/github/gh-aw/sessions/4305e874-b9da-426f-ad5b-20f433049f62 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/activation_github_token_test.go | 28 +++++++++++++++++++- pkg/workflow/compiler_activation_job.go | 14 ++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/pkg/workflow/activation_github_token_test.go b/pkg/workflow/activation_github_token_test.go index 3dfd8c32e22..170f258c61a 100644 --- a/pkg/workflow/activation_github_token_test.go +++ b/pkg/workflow/activation_github_token_test.go @@ -175,9 +175,35 @@ func TestActivationGitHubApp(t *testing.T) { // 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() diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index b1912e5a084..f85ef17d2c3 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -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() @@ -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.