Cross-repo Actions access — patterns, tradeoffs, and the OpenTofu-managed answer
Problem
Section titled “Problem”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:
- Setup once, new repos inherit without per-repo wiring
- Cross-org support (dashecorp ↔ the operator’s personal org during migration)
- Everything declared in OpenTofu — no dashboard clickery
- Short-lived tokens, no PAT rotation toil
This paper audits the nine options we considered and picks a single answer.
Comparison
Section titled “Comparison”| # | Option | Cross-org | New-repo inherit | Tofu-managed | Consumer needs token? | Verdict |
|---|---|---|---|---|---|---|
| 10 | GitHub Packages + per-package repo access grants | Yes | Yes (via Tofu on repo create) | Yes | No — package grants consumer access | Primary (new) |
| 1 | GitHub App + org secret + actions/create-github-app-token@v1 | Yes | Yes (install on “all repos”) | Mostly | Yes (minted per-run) | Fallback |
| 2 | Fine-grained PAT | Yes | Partial (re-scope PAT) | No (no API) | Yes, manually | Reject |
| 3 | Deploy keys per repo-pair | No | No | Yes | Yes | Reject |
| 4 | actions/download-artifact@v4 cross-repo | Yes (needs token) | Inherits option 1 | — | Yes | Supplementary |
| 5 | workflow_run trigger | No — same-repo only | — | — | — | Not viable |
| 6 | repository_dispatch | Yes | Inherits option 1 | Yes | Yes (for sender) | Notification only |
| 7 | Reusable workflows | Same-org or public | Yes | Yes | N/A | Orthogonal |
| 8 | ”Let GITHUB_TOKEN reach other repos” setting | Doesn’t exist for private | — | — | — | Not real |
| 9 | Internal visibility | GHE only, and policy forbids | — | — | — | Blocked |
Per-option notes
Section titled “Per-option notes”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:
- Source repo (e.g.
dashecorp/dashe-reward-e) workflow ondocs/change:on:push:paths: ['docs/**']jobs:publish-docs:runs-on: ubuntu-latestpermissions: { packages: write }steps:- uses: actions/checkout@v4- run: |tar czf docs.tgz docs/# publish as OCI artifact to ghcr.iooras push ghcr.io/${{ github.repository }}-docs:latest docs.tgz - Package settings (one-time per package, Tofu-managed): grant
dashecorp/dashe-docsread access. - Consumer (
dashe-docsaggregator):- run: |echo "$GITHUB_TOKEN" | oras login ghcr.io -u $GITHUB_ACTOR --password-stdinoras pull ghcr.io/dashecorp/dashe-reward-e-docs:latesttar 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_TOKENis 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_repositorycreate, 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_accessand related resources; where not covered, a smallterraform_data+provisioner "local-exec"callinggh 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:
| Aspect | App + org secret | Packages + per-repo access |
|---|---|---|
| Setup surface | 1 App, 2 org secrets | N packages (one per source repo), each with an access list |
| Per-new-repo cost | 0 (App already installed all-repos) | One Tofu-managed publish workflow + package-access resource |
| Ongoing cost | Mint token per aggregator run | Publish step on each source-repo docs change |
| Content freshness | On-demand (aggregator clones live) | Last published version (typically same minute) |
| Storage | Nothing extra | Package storage (GHCR/npm quotas) |
| Rotation | PEM rotation | None (short-lived tokens; package grants survive) |
| Cross-org | Install App on both orgs | Package-access grant works regardless of org boundary |
| Failure mode | Bad PEM → all aggregation breaks | One broken publish → one stale doc, rest fine |
| Who can reach the data | Anyone with the App token | Only 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-githubexpose package-access-to-repo as a first-class resource? As of 2026-04 the provider hasgithub_organization_package(for provisioning a package placeholder) but the access-grant-to-repo may still require an API call viaterraform_data+gh api. Worth a spike before committing. - Container vs npm: OCI artifacts via
orasare simplest for arbitrary files (tarballs). npm is fine if the content is structured; uses the same per-package access model.
1. GitHub App with organization secret
Section titled “1. GitHub App with organization secret”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-orgThe 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. Useselected_repositoriesto 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.
2. Fine-grained PAT as org secret
Section titled “2. Fine-grained PAT as org secret”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.
3. Deploy keys
Section titled “3. Deploy keys”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.
5. workflow_run trigger
Section titled “5. workflow_run trigger”workflow_run is same-repository only. Cannot fire dashe-docs from another repo’s workflow completion. Not viable.
6. repository_dispatch
Section titled “6. repository_dispatch”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.
7. Reusable workflows
Section titled “7. Reusable workflows”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.
9. Internal repositories
Section titled “9. Internal repositories”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.
Recommendation (revised 2026-04-22)
Section titled “Recommendation (revised 2026-04-22)”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.
Primary: Packages
Section titled “Primary: Packages”- Publish module in
dashecorp/terraform(or equivalent) that, for each dashe-* or app repo, templates:.github/workflows/publish-docs.yml— ondocs/change, tar the tree andoras push ghcr.io/{owner}/{repo}-docs:latest- Package-access grant on the resulting package allowing
dashecorp/dashe-docsto pull
dashe-docsaggregator workfloworas pulls each package; no cross-repo token needed — its ownGITHUB_TOKENsuffices because each package granted access.- New repos: Tofu module invocation → publish workflow + grant appear automatically. Per-new-repo cost: zero ongoing, one Tofu apply at creation.
- Rotation: none. Package-access grants survive indefinitely unless explicitly revoked. Tokens are short-lived GITHUB_TOKENs minted per run.
- 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:
-
Reuse
review-e-bot(already installed ondashecorpand the operator’s personal org with “All repositories”). Grant itContents: read+Metadata: read. -
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} -
dashe-docsbuild workflow mints the installation token viaactions/create-github-app-token@v1andgit clones each source repo. -
Mirror org secrets on the operator’s personal org until its archive.
-
Every new repo in dashecorp or the operator’s personal org inherits read access automatically.
Decide between them with a 1-day spike
Section titled “Decide between them with a 1-day spike”- Can
terraform-provider-githubgrant package-access-to-repo directly, or does it needgh apiglue? - Is OCI via
orasor 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.
Rejected alternatives — one-line each
Section titled “Rejected alternatives — one-line each”- PAT — no API, manual rotation.
- Deploy keys — O(repo) setup.
- Artifacts as transport — expiry makes it unreliable vs. just cloning.
workflow_runcross-repo — doesn’t exist.- Org
GITHUB_TOKENbypass — doesn’t exist for private repos. - Internal visibility — policy forbids + GHE-only.
Follow-up work
Section titled “Follow-up work”- 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.ymlthat abstracts theactions/create-github-app-token@v1call so callers just douses: dashecorp/rig-tools/.github/workflows/mint-aggregator-token.yml@main. - Update
dashe-docs/.github/workflows/deploy.ymlto use the minted token instead of the currentSTIG_JOHNNY_READ_TOKENplaceholder secret. - Document the one-time manual step: install the App on any new org we add later.