-
Notifications
You must be signed in to change notification settings - Fork 301
Description
Summary
In gh aw v0.58.3-13-g0bfb9d5eb, caller-hosted cross-repo workflow_call relays still require a manual lockfile patch when the platform workflow repository is non-public (for example, an internal repository): the activation job now correctly resolves the callee repository and ref, but the generated Checkout .github and .agents folders step does not authenticate that cross-repo checkout with a GitHub App token. In practice this leaves repo/ref resolution fixed by nearby issues such as #20658, #20697, and #20821, while activation-time runtime-import checkout still fails for non-public <org>/<platform-repo> targets unless consumers post-process the generated YAML.
Root Cause
The remaining gap is in the activation-job compiler path, not in the newer repo/ref resolution logic:
pkg/workflow/compiler_activation_job.goinbuildActivationJob()appendsgenerateCheckoutGitHubFolderForActivation(data)before any activation GitHub App token is minted, so the activation checkout cannot reference an app token even whenon.github-appis configured.pkg/workflow/checkout_manager.goexposesGenerateGitHubFolderCheckoutStep(repository, ref, getActionPin), which can emitrepository:andref:but has no way to emit atoken:field for the sparse activation checkout.pkg/workflow/safe_outputs_app_config.goimplementsbuildActivationAppTokenMintStep(app, permissions)with a hard-coded fallback ofrepositories: ${{ github.event.repository.name }}. That is correct for same-repo activation operations, but incorrect for cross-repoworkflow_callactivation checkout, which must scope the app token to the host repo name resolved fromsteps.resolve-host-repo.outputs.target_repo_name.- The activation app permission set is currently assembled only for reaction / status-comment / label-removal flows. If the token is also used for activation checkout, it must additionally include
contents: read.
This is why the generated activation checkout can now target the correct repo/ref but still cannot read a non-public platform repository in caller-hosted relays.
Recent upstream issue check:
- No exact open duplicate was found for the activation-checkout authentication gap.
- Nearby closed fixes exist, but they address different root causes:
- Bug:
Checkout actions folderemitted withoutrepository:orref:—Setup Scriptsfails in cross-repo relay #20658 fixed missing repo/ref on setup-actions checkout. - Bug: Activation checkout does not preserve callee workflow ref in caller-hosted relays #20697 / Bug: Activation checkout resolves wrong repo/ref in caller-hosted event-driven relays #20696 fixed activation checkout repo/ref selection.
- GitHub App token fallback uses full slug instead of repo name in workflow_call relays #20821 fixed repo-name fallback for app-token scoping in other paths.
call-workflowfan-out jobs do not forward declaredworkflow_call.inputsbeyond payload #21062,call-workflowgenerated caller jobs omit requiredpermissions:for reusable workflows #21071, and HTTP safe-outputs server does not register generatedcall-workflowtools #21074 fixed call-workflow fan-out/input/permission/tool-registration behavior, not activation checkout auth.
- Bug:
- Activation job missing
ref:in cross-repo checkout for workflow_call triggers #20508 is adjacent but not the same bug: it tracks missing activationref:preservation, not missing activation checkout authentication.
Affected Code
Generated YAML still emits a cross-repo activation checkout without a token:
- name: Checkout .github and .agents folders
uses: actions/checkout@<sha>
with:
persist-credentials: false
repository: ${{ steps.resolve-host-repo.outputs.target_repo }}
ref: ${{ steps.resolve-host-repo.outputs.target_ref }}
sparse-checkout: |
.github
.agents
sparse-checkout-cone-mode: true
fetch-depth: 1What is wrong:
repository:andref:correctly target the host workflow repository.- There is no
token:field, soactions/checkoutfalls back to the caller repository token. - In a non-public cross-repo relay, that token cannot read
<org>/<platform-repo>.
Current source code hard-codes the activation app token to the caller repo name instead of the resolved host repo name:
func (c *Compiler) buildActivationAppTokenMintStep(app *GitHubAppConfig, permissions *Permissions) []string {
...
steps = append(steps, fmt.Sprintf(" owner: %s\n", owner))
// Default to current repository
steps = append(steps, " repositories: ${{ github.event.repository.name }}\n")
...
}And the activation checkout generator cannot emit a token at all:
func (cm *CheckoutManager) GenerateGitHubFolderCheckoutStep(repository, ref string, getActionPin func(string) string) []string {
...
if repository != "" {
fmt.Fprintf(&sb, " repository: %s\n", repository)
}
if ref != "" {
fmt.Fprintf(&sb, " ref: %s\n", ref)
}
...
}Proposed Fix
Make activation checkout app-auth aware in the same way other checkout flows already are.
1. Allow activation app-token minting to target the resolved host repo name
Replace buildActivationAppTokenMintStep in pkg/workflow/safe_outputs_app_config.go with this version:
func (c *Compiler) buildActivationAppTokenMintStep(app *GitHubAppConfig, permissions *Permissions, fallbackRepoExpr string) []string {
safeOutputsAppLog.Printf("Building activation GitHub App token mint step: owner=%s", app.Owner)
var steps []string
steps = append(steps, " - name: Generate GitHub App token for activation\n")
steps = append(steps, " id: activation-app-token\n")
steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/create-github-app-token")))
steps = append(steps, " with:\n")
steps = append(steps, fmt.Sprintf(" app-id: %s\n", app.AppID))
steps = append(steps, fmt.Sprintf(" private-key: %s\n", app.PrivateKey))
owner := app.Owner
if owner == "" {
owner = "${{ github.repository_owner }}"
}
steps = append(steps, fmt.Sprintf(" owner: %s\n", owner))
if len(app.Repositories) == 1 && app.Repositories[0] == "*" {
// Org-wide access: omit repositories field entirely
} else if len(app.Repositories) == 1 {
steps = append(steps, fmt.Sprintf(" repositories: %s\n", app.Repositories[0]))
} else if len(app.Repositories) > 1 {
steps = append(steps, " repositories: |-\n")
for _, repo := range app.Repositories {
steps = append(steps, fmt.Sprintf(" %s\n", repo))
}
} else {
repoExpr := fallbackRepoExpr
if repoExpr == "" {
repoExpr = "${{ github.event.repository.name }}"
}
steps = append(steps, fmt.Sprintf(" repositories: %s\n", repoExpr))
}
steps = append(steps, " github-api-url: ${{ github.api_url }}\n")
if permissions != nil {
permissionFields := convertPermissionsToAppTokenFields(permissions)
keys := make([]string, 0, len(permissionFields))
for key := range permissionFields {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
steps = append(steps, fmt.Sprintf(" %s: %s\n", key, permissionFields[key]))
}
}
return steps
}2. Let sparse activation checkout emit a token: field
Replace GenerateGitHubFolderCheckoutStep in pkg/workflow/checkout_manager.go with this version:
func (cm *CheckoutManager) GenerateGitHubFolderCheckoutStep(repository, ref, token string, getActionPin func(string) string) []string {
checkoutManagerLog.Printf("Generating .github/.agents folder checkout: repository=%q ref=%q", repository, ref)
var sb strings.Builder
sb.WriteString(" - name: Checkout .github and .agents folders\n")
fmt.Fprintf(&sb, " uses: %s\n", getActionPin("actions/checkout"))
sb.WriteString(" with:\n")
sb.WriteString(" persist-credentials: false\n")
if repository != "" {
fmt.Fprintf(&sb, " repository: %s\n", repository)
}
if ref != "" {
fmt.Fprintf(&sb, " ref: %s\n", ref)
}
if token != "" {
fmt.Fprintf(&sb, " token: %s\n", token)
}
sb.WriteString(" sparse-checkout: |\n")
sb.WriteString(" .github\n")
sb.WriteString(" .agents\n")
sb.WriteString(" sparse-checkout-cone-mode: true\n")
sb.WriteString(" fetch-depth: 1\n")
return []string{sb.String()}
}3. Mint the activation token before checkout when the checkout itself needs it
In pkg/workflow/compiler_activation_job.go, compute activation-token requirements before checkout, include contents: read when the token will be used for checkout, mint the token early, and pass it into the sparse checkout generator:
hasReaction := data.AIReaction != "" && data.AIReaction != "none"
hasStatusComment := data.StatusComment != nil && *data.StatusComment
hasLabelCommand := len(data.LabelCommand) > 0
filteredLabelEvents := FilterLabelCommandEvents(data.LabelCommandEvents)
needsActivationCheckoutToken := data.ActivationGitHubApp != nil && hasWorkflowCallTrigger(data.On) && !data.InlinedImports
activationCheckoutToken := ""
if data.ActivationGitHubApp != nil && (hasReaction || hasStatusComment || hasLabelCommand || needsActivationCheckoutToken) {
appPerms := NewPermissions()
if needsActivationCheckoutToken {
appPerms.Set(PermissionContents, PermissionRead)
}
if hasReaction || hasStatusComment {
appPerms.Set(PermissionIssues, PermissionWrite)
appPerms.Set(PermissionPullRequests, PermissionWrite)
appPerms.Set(PermissionDiscussions, PermissionWrite)
}
if hasLabelCommand {
if sliceutil.Contains(filteredLabelEvents, "issues") || sliceutil.Contains(filteredLabelEvents, "pull_request") {
appPerms.Set(PermissionIssues, PermissionWrite)
}
if sliceutil.Contains(filteredLabelEvents, "discussion") {
appPerms.Set(PermissionDiscussions, PermissionWrite)
}
}
fallbackRepoExpr := ""
if needsActivationCheckoutToken {
fallbackRepoExpr = "${{ steps.resolve-host-repo.outputs.target_repo_name }}"
}
steps = append(steps, c.buildActivationAppTokenMintStep(data.ActivationGitHubApp, appPerms, fallbackRepoExpr)...)
if needsActivationCheckoutToken {
activationCheckoutToken = "${{ steps.activation-app-token.outputs.token }}"
}
}
checkoutSteps := c.generateCheckoutGitHubFolderForActivation(data, activationCheckoutToken)
steps = append(steps, checkoutSteps...)And change the activation checkout helper signature accordingly:
func (c *Compiler) generateCheckoutGitHubFolderForActivation(data *WorkflowData, token string) []string {
...
if data != nil && hasWorkflowCallTrigger(data.On) && !data.InlinedImports {
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(),
token,
GetActionPin,
)
}
return cm.GenerateGitHubFolderCheckoutStep("", "", token, GetActionPin)
}This keeps same-repo behavior unchanged, but makes cross-repo activation checkout use the same resolved host repo name / host repo ref / GitHub App auth model already used elsewhere.
Implementation Plan
-
Update activation app-token generation
- Edit
pkg/workflow/safe_outputs_app_config.go. - Change
buildActivationAppTokenMintStepto accept a fallback repository expression, matching the behavior already present inbuildGitHubAppTokenMintStep. - Preserve existing explicit
repositories:handling (*, single repo, multi-repo block scalar).
- Edit
-
Update sparse activation checkout generation
- Edit
pkg/workflow/checkout_manager.go. - Change
GenerateGitHubFolderCheckoutStepto accept an optional token expression and emitwith.tokenwhen non-empty. - Update all call sites to pass
""where no token is needed.
- Edit
-
Wire activation checkout auth into the compiler
- Edit
pkg/workflow/compiler_activation_job.go. - Compute
needsActivationCheckoutTokenbefore generating checkout steps. - Mint the activation app token before checkout when cross-repo activation checkout needs it.
- Add
contents: readto the activation app token permissions when that token is used for checkout. - Use
steps.resolve-host-repo.outputs.target_repo_nameas the fallback repo name when minting the activation checkout token. - Pass
${{ steps.activation-app-token.outputs.token }}into the activation sparse checkout step.
- Edit
-
Add / update unit tests for compiler output
- Edit
pkg/workflow/compiler_activation_job_test.go. - Add tests with names like:
TestGenerateCheckoutGitHubFolderForActivation_WorkflowCallWithTokenTestActivationCheckoutUsesAppTokenWhenWorkflowCallTargetsHostRepo
- Assert that cross-repo activation checkout emits:
repository: ${{ steps.resolve-host-repo.outputs.target_repo }}ref: ${{ steps.resolve-host-repo.outputs.target_ref }}token: ${{ steps.activation-app-token.outputs.token }}whenon.github-appis configured.
- Edit
-
Add / update activation token tests
- Edit
pkg/workflow/activation_github_token_test.go. - Add tests with names like:
TestActivationGitHubApp_UsesTargetRepoNameFallbackForWorkflowCallCheckoutTestActivationGitHubApp_IncludesContentsReadWhenUsedForActivationCheckout
- Assert that the generated activation app mint step uses:
repositories: ${{ steps.resolve-host-repo.outputs.target_repo_name }}for cross-repo activation checkout with no explicit repositories config.permission-contents: readwhen the token is reused for sparse checkout.
- Edit
-
Add an end-to-end compile regression test
- Add or extend a compile-output test that builds a minimal
workflow_callmarkdown workflow withon.github-app, compiles it, and asserts that the emitted activation job contains both the token-mint step and the tokenized sparse checkout. - A good home is either
pkg/workflow/activation_github_token_test.goor a new focused test file if the maintainers prefer smaller files.
- Add or extend a compile-output test that builds a minimal
-
Recompile / smoke-test generated workflows
- Run the project’s standard validation flow (
make agent-finish). - Ensure workflow recompilation passes and no existing cross-repo activation checkout fixtures regress.
- No documentation change seems strictly required, but if the team documents cross-repo
workflow_callGitHub App behavior anywhere indocs/orscratchpad/, add a short note that activation checkout now uses host-repo app scoping automatically.
- Run the project’s standard validation flow (
Reproduction
- Use a locally built
gh awbinary at versionv0.58.3-13-g0bfb9d5eb. - Create a caller-hosted reusable-workflow relay where:
- the caller repository is
<org>/<app-repo>; - the reusable workflow lives in a private
<org>/<platform-repo>; - the reusable workflow uses
workflow_calland runtime imports (.github/.agentssparse checkout); on.github-appis configured for activation operations.
- the caller repository is
- Run
gh aw compile --strictfor the reusable workflow and inspect the generated activation job. - Observe that the step named
Checkout .github and .agents folderstargets the resolved host repo/ref but does not emit atoken:field. - Execute the generated relay against the internal platform repository. The failing step is
Checkout .github and .agents folders; because the generated YAML uses the caller repository token instead of an app token scoped to the host repository, the checkout fails with a repository access error against<org>/<platform-repo>. - Apply a manual post-compile patch that:
- mints an activation GitHub App token scoped to the host repo name;
- grants that token
contents: read; and - passes
${{ steps.activation-app-token.outputs.token }}into the activation sparse checkout.
- Re-run the workflow. The activation checkout succeeds and runtime imports resolve correctly.