Skip to content

Activation checkout does not authenticate cross-repo .github/.agents checkout with on.github-app #21188

@johnwilliams-12

Description

@johnwilliams-12

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:

  1. pkg/workflow/compiler_activation_job.go in buildActivationJob() appends generateCheckoutGitHubFolderForActivation(data) before any activation GitHub App token is minted, so the activation checkout cannot reference an app token even when on.github-app is configured.
  2. pkg/workflow/checkout_manager.go exposes GenerateGitHubFolderCheckoutStep(repository, ref, getActionPin), which can emit repository: and ref: but has no way to emit a token: field for the sparse activation checkout.
  3. pkg/workflow/safe_outputs_app_config.go implements buildActivationAppTokenMintStep(app, permissions) with a hard-coded fallback of repositories: ${{ github.event.repository.name }}. That is correct for same-repo activation operations, but incorrect for cross-repo workflow_call activation checkout, which must scope the app token to the host repo name resolved from steps.resolve-host-repo.outputs.target_repo_name.
  4. 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:

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: 1

What is wrong:

  • repository: and ref: correctly target the host workflow repository.
  • There is no token: field, so actions/checkout falls 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

  1. Update activation app-token generation

    • Edit pkg/workflow/safe_outputs_app_config.go.
    • Change buildActivationAppTokenMintStep to accept a fallback repository expression, matching the behavior already present in buildGitHubAppTokenMintStep.
    • Preserve existing explicit repositories: handling (*, single repo, multi-repo block scalar).
  2. Update sparse activation checkout generation

    • Edit pkg/workflow/checkout_manager.go.
    • Change GenerateGitHubFolderCheckoutStep to accept an optional token expression and emit with.token when non-empty.
    • Update all call sites to pass "" where no token is needed.
  3. Wire activation checkout auth into the compiler

    • Edit pkg/workflow/compiler_activation_job.go.
    • Compute needsActivationCheckoutToken before generating checkout steps.
    • Mint the activation app token before checkout when cross-repo activation checkout needs it.
    • Add contents: read to the activation app token permissions when that token is used for checkout.
    • Use steps.resolve-host-repo.outputs.target_repo_name as the fallback repo name when minting the activation checkout token.
    • Pass ${{ steps.activation-app-token.outputs.token }} into the activation sparse checkout step.
  4. Add / update unit tests for compiler output

    • Edit pkg/workflow/compiler_activation_job_test.go.
    • Add tests with names like:
      • TestGenerateCheckoutGitHubFolderForActivation_WorkflowCallWithToken
      • TestActivationCheckoutUsesAppTokenWhenWorkflowCallTargetsHostRepo
    • 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 }} when on.github-app is configured.
  5. Add / update activation token tests

    • Edit pkg/workflow/activation_github_token_test.go.
    • Add tests with names like:
      • TestActivationGitHubApp_UsesTargetRepoNameFallbackForWorkflowCallCheckout
      • TestActivationGitHubApp_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: read when the token is reused for sparse checkout.
  6. Add an end-to-end compile regression test

    • Add or extend a compile-output test that builds a minimal workflow_call markdown workflow with on.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.go or a new focused test file if the maintainers prefer smaller files.
  7. 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_call GitHub App behavior anywhere in docs/ or scratchpad/, add a short note that activation checkout now uses host-repo app scoping automatically.

Reproduction

  1. Use a locally built gh aw binary at version v0.58.3-13-g0bfb9d5eb.
  2. 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_call and runtime imports (.github / .agents sparse checkout);
    • on.github-app is configured for activation operations.
  3. Run gh aw compile --strict for the reusable workflow and inspect the generated activation job.
  4. Observe that the step named Checkout .github and .agents folders targets the resolved host repo/ref but does not emit a token: field.
  5. 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>.
  6. 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.
  7. Re-run the workflow. The activation checkout succeeds and runtime imports resolve correctly.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions