---
title: "Speed Up Fork Pull Request Builds | StarSling"
description: "A fork pull request can read your base-branch cache but never write one, so it pays cold every time. Publish warm artifacts from a trusted producer job."
url: https://starsling.dev/github-actions/optimizations/speed-up-fork-pr-builds
canonicalUrl: https://starsling.dev/github-actions/optimizations/speed-up-fork-pr-builds
---

# Speed up fork pull request builds in GitHub Actions

[GitHub Actions](https://starsling.dev/github-actions) / [Optimizations](https://starsling.dev/github-actions/optimizations) / Speed up fork pull request builds in GitHub Actions

- [How StarSling works](https://starsling.dev/)

Rule: ci.cache.fork-pr-cold-start. Detection mode: runtime. Last updated: 2026-08-21

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.

## Table of contents

- [Do this](#do-this)
- [Avoid this](#avoid-this)
- [How to detect it](#how-to-detect-it)
- [Tradeoffs and safety](#tradeoffs)
- [Verify it worked](#verify)
- [Related pages](#related-pages)
- [Sources](#sources)

<a id="do-this"></a>

## 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.

_.github/workflows/warm-deps.yml_

```yaml
# 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 build
```

<a id="avoid-this"></a>

## Avoid this

_.github/workflows/pr-check.yml_

```yaml
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 build
```

<a id="how-to-detect-it"></a>

## How to detect it

1. Pull recent fork-triggered runs from the Actions API: `GET /repos/{owner}/{repo}/actions/runs?event=pull_request`, keeping runs whose `head_repository.fork` is true (or whose head repo `full_name` differs from the base repo).
2. For each fork PR run, pull `GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs` and sum the install and build step durations per job, so the cold cost is a measured number rather than an impression.
3. Pull the same window's same-repo branch runs (`head_repository.fork` false) 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.
4. Read a fork job's cache step in the logs. `Cache not found for input keys` on every fork run, with no `Cache saved with key` anywhere in that job, confirms the read-only asymmetry is what is costing the time rather than a key that simply never matches.
5. Check whether a trusted producer exists at all: `grep -n 'on:' -A6 .github/workflows/*.yml` for a workflow triggered by `push` to the base branch or by `schedule` that saves the dependency or image artifact these PR jobs would need. If nothing publishes it, there is nothing for the fork job to restore.

<a id="tradeoffs"></a>

## 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/restore` makes that explicit, where the combined `actions/cache` action 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_target` trigger 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 `push` to the base branch goes stale on a quiet repository. Pair it with a `schedule` trigger if fork PRs arrive against a branch that changes rarely, so the warm entry is refreshed before the cache eviction window closes.

<a id="verify"></a>

## 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:

1. 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.
2. 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.
3. 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.
4. Confirm no fork PR job logs a `Cache saved with key` line. The producer must remain the only writer; a save appearing on the untrusted side means the restore-only wiring was lost.
5. Confirm the fork job's permissions are unchanged: a read-only `GITHUB_TOKEN` and no secrets. The point of this change is that the boundary stayed exactly where it was and only the warm artifact moved.

<a id="related-pages"></a>

## Related pages

- [Prevent GitHub Actions cache poisoning](https://starsling.dev/best-practices/github-actions/prevent-github-actions-cache-poisoning)
- [Secure pull_request_target in GitHub Actions](https://starsling.dev/best-practices/github-actions/secure-pull-request-target)
- [Stop reinstalling the same dependencies in GitHub Actions](https://starsling.dev/github-actions/optimizations/avoid-duplicate-dependency-installs)

<a id="sources"></a>

## Sources

- [GitHub Actions: dependency caching (cache access restrictions)](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)
- [GitHub Actions: events that trigger workflows (pull_request)](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request)
- [GitHub Actions: security hardening for GitHub Actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions)
- [GitHub Actions REST API: workflow runs](https://docs.github.com/en/rest/actions/workflow-runs)
