CI with fork pull requests
Open-source repositories take pull requests from forks, and GitHub withholds secrets and OIDC tokens from fork-triggered runs. A fork's job therefore cannot authenticate to a cache server. mbx's own read and write policy already keeps every pull request read-only, so the remaining question is which backend each run should use.
If every job can use GitHub Actions cache, the GitHub Action's default setup already supports fork pull requests. Use this recipe when trusted runs need a private cache server: it sends trusted runs to the server and fork pull requests to the github backend.
mbx writes to the remote only from pushes to a protected branch, so protect main before expecting remote writes. Configure read and write grants on the server as well; client-side policy does not authorize access. See Authorization.
name: ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
id-token: write # GitHub withholds OIDC from fork runs on its own
env:
# a fork PR cannot mint an OIDC token, so it uses the github backend
MBX_BACKEND: >-
${{ github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name != github.repository
&& 'github' || 'remote' }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# optional: a push to main also saves a pruned Cargo target directory and
# registry to GitHub Actions cache, so fork PRs restore warm without ever
# reaching the server
- name: Mirror the target directory for fork pull requests
if: github.event_name == 'push'
uses: jdx/mr-boxington-action@v1
with:
backend: github
- uses: jdx/mr-boxington-action@v1
with:
backend: ${{ env.MBX_BACKEND }}
remote-url: https://cache.example.com
namespace: acme/backend
oidc-audience: https://cache.example.com
- run: mbx test --workspaceHow it works
- The backend expression treats pushes and same-repository pull requests as trusted, because both come from people with push access. It treats everything else as a fork. Fork runs get the
githubbackend, which restores from GitHub Actions cache without credentials and never saves from a fork pull request. id-token: writeis safe to declare at the workflow level, because GitHub refuses to issue OIDC tokens to fork-triggered runs regardless of the declared permission. For the same reason, the fork path must not pick theremotebackend: it would fail asking for a token it can never have.- The mirror step keeps fork pull requests warm. Their runs can restore only from GitHub Actions cache, and nothing would populate it if every trusted build used the server alone. On pushes to
main, the mirror runs thegithubbackend alongside the server and saves a pruned Cargo target directory and registry after the build. Fork pull requests restore that entry. Action steps clean up in reverse order, so the mirror's save runs last. Without asave-on-*input, thegithubbackend saves only after a push to the default branch, so theif:event guard is all the step needs. - To make the target directory transportable, the mirror step sets
MBX_TARGET_VIEWS=0for the rest of the job. Trusted pushes therefore build in the workspace's owntargetdirectory instead of a managed target directory. - Keep both action steps on the default
targetpayload (github-cache-mode: target). Theobjectspayload is saved under a different key, so if the mirror step saved it, fork runs would not restore it.
Separate trust levels
The single-workflow recipe trusts GitHub to withhold fork credentials. To make the trust boundary structural, split the workflow in two. A router workflow runs either a trusted or an untrusted job, never both, and each job calls a shared workflow_call implementation with its own permissions and inputs.
tak's ci.yml is a living example of the router and its final job, and tak's ci-impl.yml picks a runner pool for each route. Because tak does not use mbx's remote cache, both of its routes declare the same permissions; add id-token: write and the remote backend to your trusted route yourself.
This structure lets you enforce the following boundaries:
- The untrusted route never declares
id-token: write, and its first step can assert thatACTIONS_ID_TOKEN_REQUEST_URLis absent. - Trust can be narrower than “same repository”: tak trusts only one account, so with a cache server behind the trusted route, a compromised collaborator token still could not reach it.
- Each trust level can use its own runner pool, keeping fork code off self-hosted runners.
- A
finaljob asserts that exactly one route ran and succeeded, giving branch protection a single required check.
Pin actions to commit SHAs
Whichever shape you use, pin third-party actions to full commit SHAs in a real workflow. A mutable tag such as @v1 can be retargeted at any time.