Skip to content

A fork's pull request gets a preview, on a maintainer's click - #220

Open
abernier wants to merge 5 commits into
pmndrs:mainfrom
abernier:preview-for-forks
Open

A fork's pull request gets a preview, on a maintainer's click#220
abernier wants to merge 5 commits into
pmndrs:mainfrom
abernier:preview-for-forks

Conversation

@abernier

@abernier abernier commented Aug 15, 2026

Copy link
Copy Markdown
Member

preview-job opens with PREVIEW: ${{ secrets.VERCEL_TOKEN != '' }} and skips its build, its deploy and its deployment box when the answer is no. On a fork's pull request the answer is always no -- secrets are not readable from a pull_request run that came from a fork. #219 has a green preview-job that ran a checkout, an install, and then nothing, in 42s.

So the contributors with the least other way to look at what they changed are the ones who get no preview.

Why the token cannot simply be given to that job

Everything preview-job runs is the pull request's own code. pnpm install alone executes arbitrary postinstall scripts. pull_request withholding the secrets from a fork is not an obstacle to route around, it is the property being relied on -- and pull_request_target with a checkout of the head, or Vercel's own "build fork PRs", both hand that code a runner that can see the token.

What this does

Nothing to the path that works. preview-job keeps its inline deploy: same job, same timing, same line in the checks, and #202 stays fixed because nothing moved out of ci.yml.

What a fork's pull request adds is one thing -- an artifact -- on the branch of the if that used to do nothing at all:

  • ci.yml -- pnpm build loses its if, so a fork builds too (~1.3min, free runner). When there is no token to deploy with, out and the Pages base_path go up as an artifact instead, kept 7 days.
  • preview.yml -- new, pull_request_target on a label, fork pull requests only. Runs none of the pull request's code: no checkout of the head, no install, no build. Downloads that artifact and uploads it to Vercel.

+24 lines in ci.yml, and a file that runs only when somebody presses the button.

The button: a preview label

A maintainer puts the preview label on the pull request. One click, on the page where the decision is being made. The label comes straight back off, so the next click is another deploy.

Applying a label needs triage rights on this repository -- 5 people today, against 55 collaborators on read -- so the authorization is GitHub's own and not a check we could write wrong, and the contributor cannot label their own pull request.

pull_request_target is what lets that label event see the secrets. It is the usual footgun when a workflow checks out the pull request's head and then executes it; this one has no checkout at all. What the click decides is whether a tarball of static files goes onto the Vercel account -- a question a human can answer by looking. It is not an approval of the code: nothing here runs it, and no click could honestly vouch for what a lockfile's postinstall scripts do.

The artifact is the boundary

Everything inside it is untrusted input:

config.json, functions/ written by preview.yml, never taken from the artifact -- otherwise a pull request could put serverless functions on the account
out can only become static/. A functions/ written inside it lands under static/ as a file, not as code Vercel runs
base_path travels with the build, and reaches a Location. Validated for the one thing that matters: a leading // is somebody else's host

What is left for a fork to reach: static files it wrote, on a *.vercel.app URL, in a project with no environment variables and no domain of ours. Which is what a preview of its own code is.

What it costs

A fork's pull request pays a ~1.3min build it used to skip, on a free runner, and 235MB of artifact for 7 days -- both only when a fork opens a pull request.

Seeing it work

Before merging: the preview label has to exist on the repository, or there is nothing to click. It does now.

pull_request_target only ever runs the copy of the file on the default branch, so the label does nothing until this is merged -- a pull request that adds the mechanism cannot demonstrate it on itself. What this one shows, being a fork's pull request from abernier/pmndrs-examples: preview-job building and uploading the preview artifact with no secrets, where on #219 it skipped everything.

Then, on the first fork pull request after the merge: #219 needs one fresh CI run for its artifact to exist (close and reopen it), and the label on it is the button.

馃 Generated with Claude Code

abernier and others added 3 commits August 15, 2026 15:52
`preview-job` asked `secrets.VERCEL_TOKEN != ''` and skipped its own build
when the answer was no, which on a fork's pull request it always is: pmndrs#219
had a green `preview-job` that ran a checkout, an install, and nothing else.
Contributors who do not have push here are the ones with the least other way
to look at what they changed.

The token cannot be handed to that run -- everything it executes is the pull
request's code, `pnpm install` included -- so the deploy moves to a second
workflow that `workflow_run` starts back in this repository, with the
secrets, after the first one finishes. What makes that one safe is that it
runs none of that code: no checkout of the head, no install, no build. It
downloads the artifact and uploads it.

Which makes the artifact the boundary, and everything in it untrusted:

  - `.vercel/output/config.json` is written there, never taken from the
    artifact, and `out` can only become `static/`. Otherwise a pull request
    could land serverless functions on the account.
  - `base_path` has to travel with the build (the deploying workflow cannot
    run `configure-pages` without a permission it should not hold), and it
    reaches a `Location` header, so it is checked for the one thing that
    matters: a leading `//` is somebody else's host.
  - the sha for the deployment box comes from the `workflow_run` event, not
    from the artifact.

The cost is a ~2min build that a fork's pull request used to skip, on a free
runner, and a preview that arrives on its own run rather than as a check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The deploy job joins a `preview` environment, so its required reviewers hold
it until someone says yes.

Worth being exact about what that click is. It is not an approval of the
pull request's code -- nothing in this workflow runs that code, and no
click could honestly vouch for what a lockfile's `postinstall` scripts do,
which is why the gated-`pull_request_target` version of this feature is
weaker than it looks. It is an approval of a tarball of static files going
onto our Vercel account. A question a human can answer by looking.

So it stacks on the artifact boundary rather than replacing it: with no
reviewers set the environment lets everything through, and the workflow is
exactly as safe as it was in the previous commit.

One ordering trap, and it is why this says so in the file: GitHub creates a
missing environment on first use, unprotected. The reviewers have to exist
before this merges, or the first preview sails past the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit put the maintainer's approval on the `preview`
environment, which is a real gate and is in the Actions tab. Nobody looks at
a pull request from there. Moving it to where the decision is actually made:

  - a branch on this repository still previews by itself, on `workflow_run`,
    as it did before the split. Nobody approves a colleague's preview.
  - a fork previews when a maintainer puts the `preview` label on it. The
    label comes straight back off, so the next click is another deploy.

Adding a label needs triage rights, so the authorization is GitHub's and not
ours to get wrong -- and the contributor cannot label their own pull request.

`pull_request_target` is what lets that event see the secrets. It is a
footgun when a workflow checks out the pull request's head and then executes
it; this one has no checkout at all, and the artifact boundary from two
commits ago is unchanged. The environment stays for the URL box on the run,
without reviewers: the label is the gate, and a second one in another tab is
the thing this commit is undoing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@abernier abernier added the preview Deploy a preview for this pull request label Aug 15, 2026
abernier and others added 2 commits August 15, 2026 17:55
`workflow_run` fires when a run finishes, not when a job does. With
`preview-job` still inside `ci.yml`, the deploy would have started after the
eight test shards -- which is pmndrs#202 undone by the back door, three months
after it was measured and fixed. Measured again, on pmndrs#220:

| | |
|---|---|
| `preview-job` alone | 1.3min |
| the `ci.yml` run it sat in | 6min |
| the same run, pull request touching many examples | ~20min |

So it moves to `preview-build.yml`, a workflow that contains it and nothing
else, and finishes when it finishes.

Nothing about the build moved with it: same runner, same install, same
`BASE_PATH`, so the same `build2` hashes as `ci.yml` and the shards. The
Actions cache -- turbo's and pnpm's both -- is scoped by repository and
branch and never by workflow, so the sharing the old comment describes is
unaffected. Permissions are `ci.yml`'s verbatim rather than a guess at the
smaller set `configure-pages` needs. `main` has no branch protection, so no
required check names to follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three commits of this were spent moving `preview-job` around so that
`workflow_run` could deploy behind it. `workflow_run` was only ever there to
keep the automatic preview automatic -- and `preview-job` already does that,
today, for every pull request that can read the secrets. It never needed
replacing.

So `ci.yml` keeps its inline deploy, untouched: same job, same timing, same
line in the checks, and pmndrs#202 stays fixed because nothing moved out of it.
What a fork's pull request adds is one thing, an artifact, on the branch of
the `if` that used to do nothing at all.

`preview.yml` loses the `workflow_run` trigger with it, and is now what it
should have been from the first commit: `pull_request_target` on a label,
for a fork, on request.

The whole of it against main is now +24 lines in `ci.yml` and a file that
runs only when somebody presses the button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@abernier abernier changed the title A fork's pull request gets a preview too A fork's pull request gets a preview, on a maintainer's click Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

preview Deploy a preview for this pull request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant