Speed up fork pull request builds in GitHub Actions
A `pull_request` run from a fork has a read-only `GITHUB_TOKEN`, no repository secrets, and read-only cache access, so it can never save a cache for itself or for the next fork PR. Every fork PR pays the full cold install and build unless a trusted workflow publishes 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, but a fork PR is given read-only access: it cannot write into the base branch's scope. That asymmetry is what makes fork PRs structurally cold rather than occasionally cold. A same-repo branch that misses the cache pays once and warms it for the next run; a fork PR that misses pays every single time, because nothing it does can persist. 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 job on the critical path of every external contribution that redoes the same install and build work forever, 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 cannot
# # write one, which is the boundary working as intended.
# - 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 a fork PR misses it
# every time - and being read-only, it cannot save one for the next PR.
- 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.
Cache not found for input keyson every fork run, with noCache saved with keyanywhere in that job, confirms the read-only asymmetry is what is costing the time rather than a key that simply never matches.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 the save is refused, 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, but a fork PR has read-only access and cannot save 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-21