---
title: "Storybook PR previews on GitHub Pages without trusting PR code"
description: "Why pull_request_target is the wrong tool for PR previews, how I split build and publish with workflow_run, and three bugs that still broke previews."
canonical_url: "https://martinmasevski.dev/blog/storybook-pr-previews-github-pages-untrusted-code"
---

> Index of every page on this site: [llms.txt](https://martinmasevski.dev/llms.txt)

The first workflow I sketched for Storybook PR previews fit in one short file, and it would have let anyone with a GitHub account push to my repository.

What I wanted was simple: every pull request gets its own Storybook preview, a link where you can click through the components exactly as that pull request changes them. Netlify and Vercel do this for websites. I wanted the same thing on GitHub Pages, with nothing outside GitHub Actions.

Building Storybook in CI is easy. Publishing it is the hard part. To publish, a job needs the keys to the repository, and the thing it publishes was built from code that anyone can send.

That project became [storybook-github-pages](https://github.com/Archetipo95/storybook-github-pages), released on September 12.

*Update, October 2026: the project now continues in Storybook Swiss Knife.*

## Why `pull_request_target` is dangerous

Think of a pull request as a parcel from a stranger. You want to open it and put what's inside on display. The risk is in who opens it, and which keys they hold while they do.

GitHub has a trigger called `pull_request_target`. It runs with a token that can write to your repository, even for pull requests from forks, which means from anyone. The shortcut is to check out the pull request's code, build it and publish it, all in one job and one file.

That's opening a stranger's parcel at the desk where the master keys are lying. GitHub's Security Lab even has a name for it: a "pwn request".

The attack doesn't need anything clever. `actions/checkout` saves the token into `.git/config` by default, so a later step can push. Then the build runs `npm install` and `npm run build-storybook`, and the pull request decides what those do. A `postinstall` script that reads `.git/config` and sends the token somewhere is one line, and it can hide in a dependency the pull request adds.

The safe trigger, `pull_request`, gives forks a read-only token and no secrets. Safe, but a job with no keys can't publish anything.

So the work has to be split between two people who never trust each other.

## A mailroom and a key holder

The mailroom opens every parcel, but it has no keys. The key holder can put things on the shelf, but never opens a parcel. They only check that the label on the sealed box matches the delivery records.

In workflows, that's four files:

- **The mailroom**, `pr-preview-build.yml`. It runs on `pull_request` with read-only access. It builds the pull request, forks included, and uploads one sealed box: the built Storybook plus a label with the repository, the run id, the pull request number and the commit.
- **The key holder**, `pr-preview-publish.yml`. It runs on `workflow_run` when a build finishes, from the main branch, so a pull request can't change it, and it never checks out pull request code. It downloads the box, checks every field on the label against what GitHub itself says about that run, and only then writes the files to Pages.
- **The cleaner**, `pr-preview-cleanup.yml`. When a pull request closes, it deletes `pr-<number>/`. It reads the number and nothing else.
- **The night shift**, `pr-preview-janitor.yml`. Once a day it removes previews of pull requests that are closed or too old.

The key holder only starts work if all of this is true:

```yaml
if: >-
  github.event_name == 'workflow_run' &&
  github.event.workflow_run.conclusion == 'success' &&
  github.event.workflow_run.event == 'pull_request' &&
  github.event.workflow_run.repository.full_name == github.repository &&
  github.event.workflow_run.head_repository.full_name == github.repository &&
  github.event.workflow_run.pull_requests[0] != null
```

GitHub only fills in `workflow_run.pull_requests` for pull requests from the same repository. For forks the list is empty. It's like a guest list that only has people from inside the company on it, so outsiders never reach the door. Fork contributors still get a real build, so they see if it breaks, but nothing of theirs gets published.

`pull_requests[0]` doesn't tell you which pull request the build was for. The same branch can be open in more than one, and GitHub doesn't promise an order. So the mailroom writes the pull request number on the box itself, and the key holder checks it against the list.

The shelf doesn't come from the parcel either. The key holder decides the folder, `pr-<number>/`, from the main branch's settings, and ignores whatever the pull request says.

## Bug one: the preview from the past

Builds can finish out of order. Push twice quickly, and the first build might finish second. It's yesterday's newspaper arriving after today's and ending up on top of the pile.

So the key holder compares the commit on the box with the pull request's latest commit and skips anything old. The first version did that check, then waited in line for its turn to write to Pages. While it waited, a newer commit could land, or the pull request could close. It would then publish an old preview, or bring back one the cleaner had just removed.

The fix: check again after the wait, right before writing. Now an outdated run ends as `skip-stale`, a closed one as `skip-closed`, and running a publish job twice is harmless.

## Bug two: previews that silently disappeared

Only one job can write to the Pages branch at a time, so they all share a concurrency group, a queue. I set `cancel-in-progress: false` and assumed everyone would wait their turn.

They didn't. Without `queue: max`, a GitHub concurrency group is a waiting room with one chair. When a new run arrives, it takes the chair, and the run that was sitting there is sent home with `Canceling since a higher priority waiting request for <group> exists`. When several pull requests built at once, most previews were dropped. The pull request showed no error and no preview comment, and the preview link was a 404.

```yaml
concurrency:
  group: storybook-pages-${{ github.repository }}
  cancel-in-progress: false
  queue: max
```

With `queue: max`, up to 100 runs get a chair and go in order. A one-line fix, for a bug that every release up to 1.11.0 had.

## Bug three: the fix that shipped and didn't run

Everything in the project is pinned to an exact commit, including the actions my own workflows call. That's good for security: nobody can change what runs by moving a tag. It also gave me one of my most confusing bugs.

A fix for the preview stats shipped in 1.9.3, but previews kept showing component coverage at 100%. The workflow came from the new release, but it called an internal action pinned to an older commit. It's a new edition of a recipe book where the recipe still says "use the sauce from jar 4", and jar 4 still holds the old sauce. GitHub resolves each pinned action on its own, whatever release the workflow came from.

It was the second time. The first time, a pin pointed at a commit from before that action even existed. After the first one I added a test that checks each pinned commit exists and contains the action. After the second, the test opens the code at the pinned commit and looks for the fix itself.

## What this doesn't protect

- People inside the company are trusted. Anyone who can push a branch to the repository gets a published preview. That's the line I drew: forks get a build, collaborators get a preview. Someone with write access can already do worse than publish a page.
- Every preview lives in the same apartment. A project site lives at `<user>.github.io/<repo>/`, so the main Storybook, every preview and every other project site of the same account share one origin, the browser's idea of "the same place". Anything one of them leaves on the kitchen table, like `localStorage`, the others can read. For a component library that's fine. For anything that keeps a login or a token in the browser, give Pages its own domain, or don't use it for previews.
- The box is still from a stranger. The key holder treats it as data. It never runs anything from it, and the label only counts after it matches GitHub's own records.

## What I took away

The mailroom and the key holder came early, right after my first sketch showed me how bad the one-file version was. None of the three bugs above broke that split. Two of them came from GitHub doing exactly what its defaults say: a concurrency group that keeps one waiting run and cancels the rest, and pinned actions that resolve on their own whatever release called them.

What bothers me is how quiet they were. Bug two was in every release up to 1.11.0, and no pull request ever showed an error. Bug three shipped a fix that never ran, and I only caught it because the coverage number stayed at 100%. Both have tests now, because I don't trust myself to remember them the next time I touch that YAML.

If you build something similar, decide which job holds the keys before you write anything else. Then look up what every default you rely on actually does, the queue and the pins in my case, because that's where mine broke.

## Sitemap

See the full [sitemap](https://martinmasevski.dev/sitemap.md) for all pages.
