---
title: "Speed Up Fork Pull Request Builds | StarSling"
description: "A fork pull request reads your base-branch cache but writes only its own scope, so each new PR starts cold. 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-31

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.

## 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)
- [Symptoms that lead here](#related-symptoms)
- [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, 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.

_.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 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 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 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 build
```

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

## How to detect it

[ci-speedup](https://starsling.dev/skills/ci-speedup) carries this rule's detection logic and opens the fix as a reviewable pull request. Install it with `npx skills add starslingdev/skills`, then run `/ci-speedup` in your repository.

To check by hand:

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 across several fork PRs. `Cache not found for input keys` on the first run of each pull request, followed by `Cache saved with key` later 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.
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 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_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, 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:

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="related-symptoms"></a>

## Symptoms that lead here

Diagnosis pages whose likely causes point at this recipe. Start there if you know the symptom but not yet which fix it needs.

- [GitHub Actions cache reports a miss on every run](https://starsling.dev/github-actions/problems/github-actions-cache-not-working)

<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)
