Skip to content

Cross-repo Actions access — patterns, tradeoffs, and the OpenTofu-managed answer

dashecorp/dashe-docs aggregates docs/ trees from ~7 other private repos (some in dashecorp, some in the operator’s personal org until cutover). The default GITHUB_TOKEN is scoped to the workflow’s own repo, so any cross-repo clone 404s. We want:

  1. Setup once, new repos inherit without per-repo wiring
  2. Cross-org support (dashecorp ↔ the operator’s personal org during migration)
  3. Everything declared in OpenTofu — no dashboard clickery
  4. Short-lived tokens, no PAT rotation toil

This paper audits the nine options we considered and picks a single answer.

#OptionCross-orgNew-repo inheritTofu-managedConsumer needs token?Verdict
10GitHub Packages + per-package repo access grantsYesYes (via Tofu on repo create)YesNo — package grants consumer accessPrimary (new)
1GitHub App + org secret + actions/create-github-app-token@v1YesYes (install on “all repos”)MostlyYes (minted per-run)Fallback
2Fine-grained PATYesPartial (re-scope PAT)No (no API)Yes, manuallyReject
3Deploy keys per repo-pairNoNoYesYesReject
4actions/download-artifact@v4 cross-repoYes (needs token)Inherits option 1YesSupplementary
5workflow_run triggerNo — same-repo onlyNot viable
6repository_dispatchYesInherits option 1YesYes (for sender)Notification only
7Reusable workflowsSame-org or publicYesYesN/AOrthogonal
8”Let GITHUB_TOKEN reach other repos” settingDoesn’t exist for privateNot real
9Internal visibilityGHE only, and policy forbidsBlocked

10. GitHub Packages with per-package repo access (primary recommendation)

Section titled “10. GitHub Packages with per-package repo access (primary recommendation)”

GitHub Packages (container registry ghcr.io, npm registry npm.pkg.github.com, etc.) have their own access control — independent of the source repo’s access list. On every package you can grant “this package is accessible to these repos”. A consumer repo’s own GITHUB_TOKEN is then enough to pull the package, no shared cross-repo token needed.

Flow for dashe-docs aggregation:

  1. Source repo (e.g. dashecorp/dashe-reward-e) workflow on docs/ change:
    on:
    push:
    paths: ['docs/**']
    jobs:
    publish-docs:
    runs-on: ubuntu-latest
    permissions: { packages: write }
    steps:
    - uses: actions/checkout@v4
    - run: |
    tar czf docs.tgz docs/
    # publish as OCI artifact to ghcr.io
    oras push ghcr.io/${{ github.repository }}-docs:latest docs.tgz
  2. Package settings (one-time per package, Tofu-managed): grant dashecorp/dashe-docs read access.
  3. Consumer (dashe-docs aggregator):
    - run: |
    echo "$GITHUB_TOKEN" | oras login ghcr.io -u $GITHUB_ACTOR --password-stdin
    oras pull ghcr.io/dashecorp/dashe-reward-e-docs:latest
    tar xzf docs.tgz -C src/content/docs/apps/reward-e/
    env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # ← its own, not cross-repo

Why this wins:

  • Consumer needs no shared token; its own scoped GITHUB_TOKEN is sufficient because the package explicitly granted access
  • Cross-org works: a package published from a repo in the operator’s personal org can grant access to dashecorp/dashe-docs — the package-access grant sits on the package itself, not tied to org boundaries
  • New dashecorp/dashe-* repos auto-enroll via Tofu: on github_repository create, a follow-on resource configures the docs-publish workflow template + package access grant (see below)
  • Storage: free for private packages under orgs within quota limits

OpenTofu coverage:

  • github_repository_file — template the source-repo publish workflow into every dashe-* repo. New repos get it automatically as part of the Tofu module’s module-resource bundle.
  • Package access grants: the terraform-provider-github has github_repository_package_access and related resources; where not covered, a small terraform_data + provisioner "local-exec" calling gh api /orgs/{org}/packages/{package_type}/{package_name}/restore / access APIs bridges the gap.
  • All declarative. Onboarding a new dashe-* repo is one module invocation.

Tradeoffs vs the App pattern:

AspectApp + org secretPackages + per-repo access
Setup surface1 App, 2 org secretsN packages (one per source repo), each with an access list
Per-new-repo cost0 (App already installed all-repos)One Tofu-managed publish workflow + package-access resource
Ongoing costMint token per aggregator runPublish step on each source-repo docs change
Content freshnessOn-demand (aggregator clones live)Last published version (typically same minute)
StorageNothing extraPackage storage (GHCR/npm quotas)
RotationPEM rotationNone (short-lived tokens; package grants survive)
Cross-orgInstall App on both orgsPackage-access grant works regardless of org boundary
Failure modeBad PEM → all aggregation breaksOne broken publish → one stale doc, rest fine
Who can reach the dataAnyone with the App tokenOnly explicitly granted repos

Blast radius: App is “one identity can read everything.” Packages is “each source explicitly lists its allowed consumers.” The latter is better least-privilege.

Open questions:

  • Does terraform-provider-github expose package-access-to-repo as a first-class resource? As of 2026-04 the provider has github_organization_package (for provisioning a package placeholder) but the access-grant-to-repo may still require an API call via terraform_data + gh api. Worth a spike before committing.
  • Container vs npm: OCI artifacts via oras are simplest for arbitrary files (tarballs). npm is fine if the content is structured; uses the same per-package access model.

Install one App (DasheDocs Reader or reuse review-e-bot which is already installed on dashecorp, the operator’s personal org, and cuti-e per our agent-runner setup) with Contents: read + Metadata: read. Set the installation target to “All repositories” so every new private repo in the org is covered automatically.

Store APP_ID and the PEM private key as organization-level Actions secrets. Every workflow that needs cross-repo read writes:

- uses: actions/create-github-app-token@v1
id: token
with:
app-id: ${{ vars.AGGREGATOR_APP_ID }}
private-key: ${{ secrets.AGGREGATOR_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }} # or a literal for cross-org

The action mints a 1-hour installation token scoped to owner + optional repositories: list. Cross-org works as long as the same App is installed in the target org — which review-e-bot already is.

OpenTofu coverage:

  • github_actions_organization_secret / _variable — fully supported.
  • github_app_installation_repositories (resource docs) — manages which repos an installation covers within one org. Use selected_repositories to restrict, or configure “All repositories” in the UI (one-time manual step per org; App installation itself is not a Terraform resource).
  • App creation is not API-creatable — one-time manifest flow per App. Acceptable given we only need one.

Rotation: PEM key rotated in one place (org secret). Tokens are auto-short-lived.

Fine-grained PATs can’t be created, listed, or rotated via API — explicitly declined on the roadmap. Every scope change is a UI click. Cross-org requires two PATs (one per org). Rotation cost compounds with each repo added. Rejected.

github_repository_deploy_key terraforms cleanly, but scales O(source repos). Every new repo in scope means a new keypair + a new repo-level secret on the aggregator. Read-only at repo granularity, no org-level equivalent. Rejected for “set once, inherit” goal.

4. actions/download-artifact@v4 cross-repo

Section titled “4. actions/download-artifact@v4 cross-repo”

v4 supports repository:, run-id:, github-token: — can pull artifacts from another repo’s run. But: artifacts expire after 90 days by default, so if a source repo hasn’t built recently the aggregator has to fall back to git archive via token anyway. Strictly worse than just cloning via App token. Keep in the toolbox for cases where we need a specific build output, not for docs aggregation.

workflow_run is same-repository only. Cannot fire dashe-docs from another repo’s workflow completion. Not viable.

Max client_payload is 65,535 bytes per REST docs. Enough for a “docs changed, rebuild” ping but nowhere near enough to carry a docs tree. Already our notification mechanism (docs-update event type); aggregator then pulls via App token. Keep as-is.

uses: dashecorp/dashe-docs/.github/workflows/docs-export.yml@main works across same-org private repos when the callee’s Actions Settings → Access allows “Accessible from repositories in the organization”. Cross-org reusable workflows require the callee to be public or internal — blocked by our “all repos private” rule, so legacy personal-org repos can’t consume a dashecorp reusable workflow directly.

Useful pattern: thin per-repo “export docs” workflow in each dashecorp app repo calling a reusable workflow at dashecorp/rig-tools/.github/workflows/notify-docs-aggregator.yml. Legacy personal-org repos get a copy of the caller (still small — 3-5 lines) managed via github_repository_file.

8. Org-level “let GITHUB_TOKEN reach other repos”

Section titled “8. Org-level “let GITHUB_TOKEN reach other repos””

Doesn’t exist for private repos. The setting people confuse it with is either “Allow GitHub Actions to create and approve pull requests” (different thing) or the reusable-workflow access scope (option 7). GITHUB_TOKEN is always scoped to the running repo. No 2025–2026 change.

Internal visibility exists only on GitHub Enterprise Cloud / Enterprise Server, and our policy is “all repos private”. Even ignoring policy, GHE-only means it doesn’t apply to our current plan. Skip.

Option 10 (Packages with per-package repo access) as the primary path. Option 1 (App + org secret) stays documented as the fallback if a spike shows the Tofu surface for package-access grants is too immature.

  1. Publish module in dashecorp/terraform (or equivalent) that, for each dashe-* or app repo, templates:
    • .github/workflows/publish-docs.yml — on docs/ change, tar the tree and oras push ghcr.io/{owner}/{repo}-docs:latest
    • Package-access grant on the resulting package allowing dashecorp/dashe-docs to pull
  2. dashe-docs aggregator workflow oras pulls each package; no cross-repo token needed — its own GITHUB_TOKEN suffices because each package granted access.
  3. New repos: Tofu module invocation → publish workflow + grant appear automatically. Per-new-repo cost: zero ongoing, one Tofu apply at creation.
  4. Rotation: none. Package-access grants survive indefinitely unless explicitly revoked. Tokens are short-lived GITHUB_TOKENs minted per run.
  5. Least-privilege: each package explicitly lists its consumers. Unlike the App, there is no “one identity that reads everything.”

Fallback: App + org secret (original draft)

Section titled “Fallback: App + org secret (original draft)”

If the spike on terraform-provider-github package-access-to-repo resources is incomplete enough that we’d need substantial API glue, fall back to:

  1. Reuse review-e-bot (already installed on dashecorp and the operator’s personal org with “All repositories”). Grant it Contents: read + Metadata: read.

  2. Declare organization secrets in dashecorp/terraform:

    resource "github_actions_organization_variable" "aggregator_app_id" {
    variable_name = "AGGREGATOR_APP_ID"
    visibility = "all"
    value = "<review-e-bot App ID>"
    }
    resource "github_actions_organization_secret" "aggregator_app_private_key" {
    secret_name = "AGGREGATOR_APP_PRIVATE_KEY"
    visibility = "all"
    plaintext_value = file("./secrets/review-e-bot.pem") # SOPS-encrypted
    }
  3. dashe-docs build workflow mints the installation token via actions/create-github-app-token@v1 and git clones each source repo.

  4. Mirror org secrets on the operator’s personal org until its archive.

  5. Every new repo in dashecorp or the operator’s personal org inherits read access automatically.

  • Can terraform-provider-github grant package-access-to-repo directly, or does it need gh api glue?
  • Is OCI via oras or a proper npm package the cleaner artifact format for a tarball of markdown?
  • What’s the actual publish latency (push-to-aggregator-pull gap)?

If spike results are clean → go Packages. If the package-access grants need significant local-exec bridging, go App.

  • PAT — no API, manual rotation.
  • Deploy keys — O(repo) setup.
  • Artifacts as transport — expiry makes it unreliable vs. just cloning.
  • workflow_run cross-repo — doesn’t exist.
  • Org GITHUB_TOKEN bypass — doesn’t exist for private repos.
  • Internal visibility — policy forbids + GHE-only.
  • Terraform PR in dashecorp/infra (or wherever the GitHub provider blocks live) to declare the two org secrets; wire SOPS for the PEM.
  • Thin reusable workflow at dashecorp/rig-tools/.github/workflows/mint-aggregator-token.yml that abstracts the actions/create-github-app-token@v1 call so callers just do uses: dashecorp/rig-tools/.github/workflows/mint-aggregator-token.yml@main.
  • Update dashe-docs/.github/workflows/deploy.yml to use the minted token instead of the current STIG_JOHNNY_READ_TOKEN placeholder secret.
  • Document the one-time manual step: install the App on any new org we add later.