- GitHub Actions optimizations
- Speed up fork pull request builds in GitHub Actions
Speed up fork pull request builds in GitHub Actions
A `pull_request` run from a fork has a read-only `GITHUB_TOKEN` and no repository secrets, and its cache writes land in that pull request's own merge-ref scope, never the base branch's. So the first run of every fork PR pays the full cold install and build unless a trusted workflow has published a warm artifact under a key the fork job can compute and restore read-only.
ci.cache.fork-pr-cold-startruntime · needs run historyAI agents open the PR
StarSling agents run this exact audit on your workflows, apply the fix, and open a reviewable PR automatically.
Do this
Cache access on GitHub Actions is directional. A run triggered for a pull request can restore caches created in the base branch, including for pull requests from forks, and it does write - but only into that pull request's own merge-ref scope (refs/pull/.../merge), never into the base branch's. So the entry a fork PR saves is restorable by later runs of that same pull request and by nothing else. A same-repo branch that misses the cache pays once and warms it for every run after it; each new fork PR starts from whatever the base branch holds, and from nothing when the base branch holds nothing under a key the fork job can compute. That is a cold install and build on the first run of every external contribution, repeated per contributor and per pull request. Add that a fork run carries no secrets and a read-only token, so any warm path behind authentication - a prebuilt image in a private registry, an authenticated package mirror, an artifact download that needs a token - is closed to it as well. The result is a slow check on the critical path of every external contribution, which is exactly the contribution people abandon when the checks take too long. The fix works with the boundary rather than against it: a trusted workflow does the expensive part once and publishes the result where the fork job is already allowed to read it.
# The trusted producer: runs on the base branch, so it MAY write the cache.
name: Warm deps
on:
push:
branches: [main]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
# Keyed only on the lockfile, so a fork job can recompute this key
# from its own checkout without trusting anything the producer says.
- uses: actions/cache@v4
with:
path: node_modules
key: modules-${{ hashFiles('package-lock.json') }}
- run: npm ci
# The untrusted consumer, in .github/workflows/pr-check.yml:
#
# jobs:
# build:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-node@v4
# with:
# node-version: 20
# # restore-only: a fork PR reads the base-branch entry and writes
# # nothing back, keeping the producer the single writer.
# - uses: actions/cache/restore@v4
# id: deps
# with:
# path: node_modules
# key: modules-${{ hashFiles('package-lock.json') }}
# # Local fallback: a miss costs a cold install, never a failed check.
# - if: steps.deps.outputs.cache-hit != 'true'
# run: npm ci
# - run: npm run buildAvoid this
name: PR check
on:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
# Nothing publishes this key from a trusted run, so every fork PR opens
# cold - what this job saves is scoped to this PR and no other can read it.
- uses: actions/cache@v4
with:
path: node_modules
key: modules-${{ hashFiles('package-lock.json') }}
- run: npm ci
- run: npm run buildHow to detect it
Pull recent fork-triggered runs from the Actions API:
GET /repos/{owner}/{repo}/actions/runs?event=pull_request, keeping runs whosehead_repository.forkis true (or whose head repofull_namediffers from the base repo).For each fork PR run, pull
GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobsand sum the install and build step durations per job, so the cold cost is a measured number rather than an impression.Pull the same window's same-repo branch runs (
head_repository.forkfalse) and sum the same steps. A fork population whose install and build steps consistently match a guaranteed-cold same-repo run, run after run, is the finding; a fork population that tracks the warm same-repo runs is already being served by a producer.Read a fork job's cache step in the logs across several fork PRs.
Cache not found for input keyson the first run of each pull request, followed byCache saved with keylater in that same job, is the signature: the write works, but it lands in that PR's own merge-ref scope, so the next pull request opens cold again. That is the scope boundary costing the time rather than a key that never matches, which would instead show a miss on every run of the same PR including its re-runs.Check whether a trusted producer exists at all:
grep -n 'on:' -A6 .github/workflows/*.ymlfor a workflow triggered bypushto the base branch or byschedulethat saves the dependency or image artifact these PR jobs would need. If nothing publishes it, there is nothing for the fork job to restore.
Tradeoffs and safety
Key the published artifact on something the consumer recomputes from its own checkout, such as a lockfile hash or the base-branch SHA. A key the producer alone decides means the fork job restores whatever it is handed; a key the consumer derives from its own inputs means a mismatched or tampered entry simply does not match, and the job falls through to the cold path.
The consumer must restore, never save.
actions/cache/restoremakes that explicit, where the combinedactions/cacheaction registers a post-job save step; on a fork PR that save succeeds into the pull request's own scope, where it helps nothing and no later pull request can read it, and on a same-repo PR from a maintainer's branch it would write an entry built from PR code. Restore-only keeps the producer as the single writer on both.Always pair the restore with a local fallback that does the cold work when the key misses. A fork PR that fails because a producer has not run yet is worse than a slow one, and misses are normal: a lockfile change lands in the PR before any producer has ever seen it.
Publish only what is safe to hand an untrusted job. Dependency trees and base image layers are fine; anything derived from a secret, a token, or a credential file must never enter an artifact a fork PR can read, because anyone able to open a pull request can then read it.
Never close the gap by giving fork jobs write access, secrets, or a
pull_request_targettrigger that checks out PR code. That converts a slow check into a cache-poisoning and secret-exfiltration path, which is a far more expensive problem than the build time it saves.A producer that runs only on
pushto the base branch goes stale on a quiet repository. Pair it with ascheduletrigger if fork PRs arrive against a branch that changes rarely, so the warm entry is refreshed before the cache eviction window closes.
Verify it worked
Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to run this audit and open the fix as a reviewable PR.
Investigate whether fork pull request jobs in this repository pay avoidable cold-build
cost, and fix it with a trusted-producer split rather than by moving the trust boundary.
1. Read the upstream docs linked on this page first, especially the cache access
restrictions: a pull request run can RESTORE caches from the base branch, including
for forks, and it SAVES only into that pull request's own merge-ref scope, never the
base branch's, so no fork PR can warm another one. That asymmetry is the whole basis
of the fix.
2. Pull recent runs: GET /repos/{owner}/{repo}/actions/runs?event=pull_request. Separate
runs whose head_repository is a fork from same-repo branch runs.
3. For a sample of each, pull GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs and sum
the install and build step durations. Confirm the fork population tracks cold same-repo
runs, and quote the run ids and durations you compared.
4. Look for an existing trusted producer: a workflow on `push` to the base branch or on
`schedule` that saves the dependency tree or image layers these PR jobs need. If none
exists, propose one, keyed on a value the consumer recomputes from its own checkout
(a lockfile hash, or the base-branch SHA).
5. Change the PR workflow's cache step to restore-only (`actions/cache/restore`) and add a
fallback step that does the cold work when `cache-hit` is not 'true', so a miss is slow
rather than fatal.
6. Do NOT propose any change that gives a fork PR job write access to a cache, access to a
secret, or a `pull_request_target` trigger that checks out PR code. If an existing
workflow already does one of those, report it as a separate higher-priority security
finding and point at this page's cache-poisoning and pull_request_target links.
7. Confirm nothing derived from a secret or credential is included in what the producer
publishes; anyone who can open a pull request can read it.
8. Show the full diff and open a pull request; do not apply changes blindly. In the PR
body, cite the fork PR run ids and durations you measured, and state how to verify:
open a fork PR and confirm its log shows a cache restore, then change the lockfile and
confirm the job still passes via the fallback.Confirm the change landed
Re-pull fork PR runs after the change and read the consumer's restore step:
Cache restored from key: modules-...on a fork run is the proof the boundary allows the read, which is the whole mechanism.Compare install and build step durations for fork PR jobs before and after, using the same API sampling as detection. The saving should appear on the fork population specifically, since same-repo runs were already warm.
Force a miss on purpose - open a fork PR that changes the lockfile - and confirm the job still passes by falling through to the cold install rather than failing on the absent key.
Confirm no fork PR job logs a
Cache saved with keyline. The producer must remain the only writer; a save appearing on the untrusted side means the restore-only wiring was lost.Confirm the fork job's permissions are unchanged: a read-only
GITHUB_TOKENand no secrets. The point of this change is that the boundary stayed exactly where it was and only the warm artifact moved.
Go further
One fix, all of them, or forever.
You have the prompt for this one practice. Here is how much further you can take it, each step doing more for you than the last.
Fix this one thing
Copy the prompt above
Hand OPT74 to your coding agent and fix it in your repo today.
Fix everything, once
Install the ci-speedup skill
One prompt audits your whole repo against all 73 ci-speedup patterns (this one plus 72 more) and hands your agent every fix at once. Open source, MIT, runs locally.
Keep it fixed, forever
Install the StarSling GitHub App
Connect GitHub and the fixes stay applied as your CI evolves, with agents that keep inspecting your workflows and opening optimization PRs you review.
Sources
1GitHub Actions: dependency caching (cache access restrictions) (opens in new tab)
2GitHub Actions: events that trigger workflows (pull_request) (opens in new tab)
3GitHub Actions: security hardening for GitHub Actions (opens in new tab)
Last updated 2026-08-31