Skip to content

Lockdown check fails on private cross-repo workflow_call: Contents API uses caller's GITHUB_TOKEN, not callee-accessible token #32312

@norrietaylor

Description

@norrietaylor

Version: gh-aw v0.72.1

Symptom

Every consumer workflow_call dispatch against a private gh-aw callee fails at the lock.yml's activation step with:

Cross-repo invocation detected: workflow source is "<callee>",
  current repo is "<caller>"
GET /repos/<callee>/contents/.github/workflows/<chore>.lock.yml
    ?ref=<callee-ref-sha> — 404 Not Found
Could not fetch content for .github/workflows/<chore>.lock.yml: Not Found
Unable to fetch lock file content for hash comparison via API,
  trying local filesystem fallback
Local lock file not found: …/work/<caller>/<caller>/.github/workflows/<chore>.lock.yml
##[warning]Could not compare frontmatter hashes — assuming lock file is outdated
##[error]ERR_CONFIG: Lock file '.github/workflows/<chore>.lock.yml' is outdated
  or unverifiable! Could not verify frontmatter hash for
  '.github/workflows/<chore>.md'. Run 'gh aw compile' to regenerate the lock file.

The check_workflow_timestamp_api.cjs activation step then sets stale_lock_file_failed=true, and the conclusion job marks the whole run failed before the agent ever runs.

Concrete reproducer: gominimal/min-ctl run 25906428806 calls gominimal/min-aw/.github/workflows/worker-fix.lock.yml@v0.6.3. Both repos private, same org. Fails as above on every dispatch.

Root cause

check_workflow_timestamp_api.cjs issues the Contents API call against the callee repo using GITHUB_TOKEN from the caller's runner. For a private callee:

  • GITHUB_TOKEN on a workflow run is repo-scoped to the running repo (the caller, e.g. gominimal/min-ctl).
  • The Contents API on a private repo requires read access to that specific repo.
  • gominimal/min-ctl's GITHUB_TOKEN has no scope on gominimal/min-aw → 404, even though both repos are in the same org and workflow_call itself was already authorized via repo-actions-access settings.

This works fine when the callee is public (elastic/ai-github-actions is public; their consumers don't see this) — public Contents API doesn't require auth. The gap is private-callee-private-caller.

Why the existing fallbacks don't help

  1. API path uses GITHUB_TOKEN → 404 for private callees.
  2. GH_AW_GITHUB_TOKEN fallback secret slot is declared optional in the lock.yml's workflow_call.secrets. Consumers can provide a token here, but the documentation/templates don't make this clear, and there's no practical way to mint a short-lived cross-repo token without long-lived PAT storage (security cost).
  3. Local filesystem fallback looks for the lock.yml on the caller's checkout. Consumers using the wrapper distribution pattern don't ship .lock.yml files locally (that's the point — thin wrappers).

Proposed fix

The lock.yml already receives APP_PRIVATE_KEY via workflow_call.secrets (used for safe-output writes via actions/create-github-app-token). If the corresponding GitHub App is installed on the callee repo as well, an App-minted installation token has cross-repo Contents read access.

check_workflow_timestamp_api.cjs should:

  1. Detect cross-repo invocation (it already does — emits "Cross-repo invocation detected" log).
  2. Detect that the initial Contents API call returned 404 / 401 / 403.
  3. Mint a fresh installation token using APP_PRIVATE_KEY + vars.APP_ID (both already available to the activation step), scoped to the callee repo.
  4. Retry the Contents API call with the minted token.
  5. If the mint fails (no APP_PRIVATE_KEY in secrets, App not installed on callee, etc.), fall through to today's behavior with a clearer error message: "Configure GH_AW_GITHUB_TOKEN with cross-repo Contents read access, or install the gh-aw App on the callee repo."

Alternative if internal mint is too invasive: emit a clearer error directing consumers to set up GH_AW_GITHUB_TOKEN. The current message ("outdated or unverifiable") sends users toward gh aw compile, which doesn't address the auth gap.

Workaround for current users

Ship .lock.yml mirrors on the consumer side alongside .md mirrors. The filesystem fallback then succeeds. Trade-off: ~80–90 KB per chore on the consumer (defeats the thin-wrapper goal). See gominimal/min-ctl#66 for the workaround in practice.

Related

Metadata

Metadata

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