From 489cfb51ce444d4139a95ec9c277d7d71766b41f Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Sat, 15 Aug 2026 15:52:08 +0200 Subject: [PATCH 1/6] A fork's pull request gets a preview too `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: #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 --- .github/workflows/ci.yml | 70 +++++++-------------- .github/workflows/preview.yml | 114 ++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/preview.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdf82cd5..63bbbeaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -270,13 +270,21 @@ jobs: # Which is what a preview is for -- `chromatic-job` publishes on `always()` # for the same reason -- and merging is still gated on the tests. # + # It builds, and it does not deploy. This workflow runs on `pull_request`, + # which for a fork's pull request means a read-only token and no access to + # the repo secrets -- and that is the property worth keeping, because + # everything this job runs is the pull request's own code (`pnpm install` + # alone is arbitrary `postinstall` scripts). So the deployment moved to + # `preview.yml`, which `workflow_run` runs back in this repository's context + # with the Vercel token, and which never executes any of it: it unpacks the + # artifact below and uploads it. The artifact is the boundary between the two. + # + # What it costs: a fork's pull request now pays this ~2min build where it used + # to skip it -- on a public repository, a free runner. + # preview-job: if: ${{ github.event_name == 'pull_request' }} runs-on: ubuntu-latest - env: - # No token, no preview: that is a fork's pull request, which cannot read - # the repo secrets, or a checkout of this repo that never set them. - PREVIEW: ${{ secrets.VERCEL_TOKEN != '' }} steps: - uses: actions/checkout@v7 - id: configurepages @@ -302,7 +310,6 @@ jobs: # domain root, since the pages link to each other by absolute path. That # is the production layout, which is what a preview should be showing. - run: pnpm build - if: ${{ env.PREVIEW == 'true' }} env: BASE_PATH: ${{ steps.configurepages.outputs.base_path }} # The production origin, deliberately: the URLs are canonical @@ -310,49 +317,18 @@ jobs: # them too -- besides, a different value is a different `build2` # hash, and these have to be the hashes everything else produces. BASE_URL: ${{ steps.configurepages.outputs.base_url }} - - id: preview - if: ${{ env.PREVIEW == 'true' }} - env: - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - BASE_PATH: ${{ steps.configurepages.outputs.base_path }} - run: | - mkdir -p .vercel/output - # `out`, not `out${BASE_PATH}`: the site lives one level down, and the - # static root has to be the level its absolute links resolve against - mv out .vercel/output/static - # `cleanUrls`: the website exports flat `.html` pages and links - # to them without the extension. The route sends the bare deployment - # URL -- the one Vercel prints, and the one anyone trims to -- to the - # home page, which now sits under the base path rather than at `/`. - printf '{"version":3,"cleanUrls":true,"routes":[{"src":"/","status":308,"headers":{"Location":"%s"}}]}' \ - "${BASE_PATH:-/}" > .vercel/output/config.json - # `--archive`: one tarball, not 2.4k file uploads - url=$(pnpm dlx vercel@latest deploy --prebuilt --archive=tgz --token="$VERCEL_TOKEN") - echo "url=$url$BASE_PATH" >> "$GITHUB_OUTPUT" - # The pull request's "View deployment" box, which Vercel's git integration - # would have filled had we let it deploy - - uses: actions/github-script@v9 - if: ${{ env.PREVIEW == 'true' }} - env: - URL: ${{ steps.preview.outputs.url }} + + # `base_path` travels with the build because the job that deploys it + # cannot recompute it: `configure-pages` reads the Pages settings, and + # asking for that permission in a workflow that holds the Vercel token + # buys nothing. `preview.yml` validates the value before it uses it. + - run: echo "${{ steps.configurepages.outputs.base_path }}" > base_path + - uses: actions/upload-artifact@v7 with: - script: | - const { data } = await github.rest.repos.createDeployment({ - ...context.repo, - ref: context.payload.pull_request.head.sha, - environment: "preview", - transient_environment: true, // superseded by the next one - auto_merge: false, // deploy that ref, not a merge of the base into it - required_contexts: [], // the checks that would gate it are the ones running this - }); - await github.rest.repos.createDeploymentStatus({ - ...context.repo, - deployment_id: data.id, - state: "success", - environment_url: process.env.URL, - }); + name: preview + path: | + out + base_path deploy-job: # only for pushes on main diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 00000000..dc0e83fa --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,114 @@ +# +# Preview +# +# The half of `ci.yml`'s `preview-job` that needs the Vercel token, split off +# so that a fork's pull request can have a preview at all. +# +# `pull_request` gives a fork's run a read-only token and no secrets, on +# purpose: everything it runs is the pull request's code. `workflow_run` is the +# way back -- it runs here, in this repository, on the default branch's copy of +# this file, with the secrets -- and it is only safe for as long as this +# workflow runs none of that code. So: no checkout of the head, no +# `pnpm install`, no build. It downloads what `ci.yml` built and uploads it. +# +# The artifact is the boundary, and everything inside it is untrusted input. +# Two consequences, both of them below: the Build Output API's own directories +# (`functions/`, `config.json`) are written here and never taken from the +# artifact -- otherwise a pull request could put serverless functions on this +# account -- and `base_path`, which reaches a `Location` header, is checked +# before it is used. +# +# What is left for the fork to reach: static files it wrote, on a +# `*.vercel.app` URL, in a project that has no environment variables and no +# domain of ours. Which is what a preview of its own code is. +# +name: Preview + +on: + workflow_run: + workflows: [CI] + types: [completed] + +# Deliberately not the repository defaults: this workflow holds the token, so +# it gets the two permissions it uses and nothing else. `actions: read` is what +# reads another run's artifact. +permissions: + actions: read + deployments: write + +jobs: + preview-job: + # A push on main has its own deployment, and `workflow_run` fires for it too + if: ${{ github.event.workflow_run.event == 'pull_request' }} + runs-on: ubuntu-latest + env: + # No token, no preview: a checkout of this repository that never set them + PREVIEW: ${{ secrets.VERCEL_TOKEN != '' }} + steps: + # No artifact is a build that failed, and the run goes red here rather + # than on the pull request -- where `preview-job` is already red for it. + - uses: actions/download-artifact@v8 + if: ${{ env.PREVIEW == 'true' }} + with: + name: preview + path: artifact + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - id: preview + if: ${{ env.PREVIEW == 'true' }} + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + run: | + base_path=$(cat artifact/base_path) + # It came out of a job that ran the pull request's code, and it ends + # up in a `Location`. A leading `//` is a URL to somebody else's host, + # which is the whole of what could go wrong here. + case "$base_path" in + //*|*[!A-Za-z0-9._/-]*) echo "refusing base_path: $base_path"; exit 1 ;; + /*|"") ;; + *) echo "refusing base_path: $base_path"; exit 1 ;; + esac + + mkdir -p .vercel/output + # `out`, not `out${base_path}`: the site lives one level down, and the + # static root has to be the level its absolute links resolve against. + # `static/` is also the only thing the artifact is allowed to become + # -- a `functions/` the pull request wrote inside `out` lands under it + # as a file, not as code Vercel would run. + mv artifact/out .vercel/output/static + # `cleanUrls`: the website exports flat `.html` pages and links + # to them without the extension. The route sends the bare deployment + # URL -- the one Vercel prints, and the one anyone trims to -- to the + # home page, which now sits under the base path rather than at `/`. + printf '{"version":3,"cleanUrls":true,"routes":[{"src":"/","status":308,"headers":{"Location":"%s"}}]}' \ + "${base_path:-/}" > .vercel/output/config.json + # `--archive`: one tarball, not 2.4k file uploads + url=$(npx --yes vercel@latest deploy --prebuilt --archive=tgz --token="$VERCEL_TOKEN") + echo "url=$url$base_path" >> "$GITHUB_OUTPUT" + + # The pull request's "View deployment" box, which Vercel's git integration + # would have filled had we let it deploy. The sha is the event's, not the + # artifact's: a fork's head commit is reachable here as `refs/pull/N/head`. + - uses: actions/github-script@v9 + if: ${{ env.PREVIEW == 'true' }} + env: + URL: ${{ steps.preview.outputs.url }} + with: + script: | + const { data } = await github.rest.repos.createDeployment({ + ...context.repo, + ref: context.payload.workflow_run.head_sha, + environment: "preview", + transient_environment: true, // superseded by the next one + auto_merge: false, // deploy that ref, not a merge of the base into it + required_contexts: [], // the checks that would gate it are the ones running this + }); + await github.rest.repos.createDeploymentStatus({ + ...context.repo, + deployment_id: data.id, + state: "success", + environment_url: process.env.URL, + }); From 68582ec390be352b7638d0e24207ae1d27a9e86b Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Sat, 15 Aug 2026 17:35:32 +0200 Subject: [PATCH 2/6] A maintainer approves what lands on the account 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 --- .github/workflows/preview.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index dc0e83fa..4adfb2b2 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -22,6 +22,9 @@ # `*.vercel.app` URL, in a project that has no environment variables and no # domain of ours. Which is what a preview of its own code is. # +# A maintainer approves each one on top of that -- see `environment` below, +# which is careful about what that click is and is not. +# name: Preview on: @@ -41,6 +44,28 @@ jobs: # A push on main has its own deployment, and `workflow_run` fires for it too if: ${{ github.event.workflow_run.event == 'pull_request' }} runs-on: ubuntu-latest + + # The environment's required reviewers hold this job until a maintainer + # approves it, and what they are approving is worth being precise about: + # not the pull request's code -- nothing here runs it, and no click could + # honestly vouch for a lockfile's `postinstall` scripts anyway -- but a + # tarball of static files going onto our Vercel account. A question a human + # can actually answer. + # + # It is a gate, not the safety property: with no reviewers configured the + # environment lets every run straight through, and everything above still + # holds. Note that GitHub creates the environment on first use if it does + # not exist, unprotected -- so the reviewers have to be set before this + # merges, or the first preview sails past ungated. + # + # `url` is what the run's environment box links to, since the deployment + # this job records lands on the default branch's ref (a `workflow_run` runs + # there, not on the pull request); the box on the pull request itself is the + # deployment created at the end, against the fork's head sha. + environment: + name: preview + url: ${{ steps.preview.outputs.url }} + env: # No token, no preview: a checkout of this repository that never set them PREVIEW: ${{ secrets.VERCEL_TOKEN != '' }} From 8c9fea513f8075b96359ddf3e8668dc12710c6d0 Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Sat, 15 Aug 2026 17:44:12 +0200 Subject: [PATCH 3/6] The button is a label on the pull request 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 --- .github/workflows/preview.yml | 103 ++++++++++++++++++++++++---------- 1 file changed, 72 insertions(+), 31 deletions(-) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 4adfb2b2..2376d536 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -5,11 +5,11 @@ # so that a fork's pull request can have a preview at all. # # `pull_request` gives a fork's run a read-only token and no secrets, on -# purpose: everything it runs is the pull request's code. `workflow_run` is the -# way back -- it runs here, in this repository, on the default branch's copy of -# this file, with the secrets -- and it is only safe for as long as this -# workflow runs none of that code. So: no checkout of the head, no -# `pnpm install`, no build. It downloads what `ci.yml` built and uploads it. +# purpose: everything it runs is the pull request's code. So the deploy happens +# here instead, in a workflow that runs in this repository, with the secrets -- +# and that is only safe for as long as it runs none of that code. It does not: +# no checkout of the head, no `pnpm install`, no build. It downloads what +# `ci.yml` built and uploads it. # # The artifact is the boundary, and everything inside it is untrusted input. # Two consequences, both of them below: the Build Output API's own directories @@ -22,8 +22,23 @@ # `*.vercel.app` URL, in a project that has no environment variables and no # domain of ours. Which is what a preview of its own code is. # -# A maintainer approves each one on top of that -- see `environment` below, -# which is careful about what that click is and is not. +# Two doors in, and which one a pull request gets is the only difference the +# fork makes: +# +# - a branch on this repository -- `workflow_run`, as soon as CI has built +# it. Nobody approves their own colleague's preview, and that is how this +# behaved before the split. +# +# - a fork -- nothing, until a maintainer puts the `preview` label on the +# pull request. That label is the button: adding one needs triage rights +# here, which is exactly the set of people who should be deciding, and the +# contributor cannot add it to their own. It comes straight back off, so +# the next click is another deploy. +# +# `pull_request_target` is what makes that label event able to see the secrets. +# The reason it is usually a footgun is a checkout of the pull request's head +# followed by anything that executes it -- which is the one thing this file is +# built not to do. # name: Preview @@ -31,37 +46,30 @@ on: workflow_run: workflows: [CI] types: [completed] + pull_request_target: + types: [labeled] # Deliberately not the repository defaults: this workflow holds the token, so -# it gets the two permissions it uses and nothing else. `actions: read` is what -# reads another run's artifact. +# it gets the permissions it uses and nothing else. `actions: read` reads +# another run's artifact, `pull-requests: write` takes the label back off. permissions: actions: read deployments: write + pull-requests: write jobs: preview-job: - # A push on main has its own deployment, and `workflow_run` fires for it too - if: ${{ github.event.workflow_run.event == 'pull_request' }} + if: >- + ${{ (github.event_name == 'workflow_run' + && github.event.workflow_run.event == 'pull_request' + && github.event.workflow_run.head_repository.full_name == github.repository) + || (github.event_name == 'pull_request_target' + && github.event.label.name == 'preview') }} runs-on: ubuntu-latest - - # The environment's required reviewers hold this job until a maintainer - # approves it, and what they are approving is worth being precise about: - # not the pull request's code -- nothing here runs it, and no click could - # honestly vouch for a lockfile's `postinstall` scripts anyway -- but a - # tarball of static files going onto our Vercel account. A question a human - # can actually answer. - # - # It is a gate, not the safety property: with no reviewers configured the - # environment lets every run straight through, and everything above still - # holds. Note that GitHub creates the environment on first use if it does - # not exist, unprotected -- so the reviewers have to be set before this - # merges, or the first preview sails past ungated. - # - # `url` is what the run's environment box links to, since the deployment - # this job records lands on the default branch's ref (a `workflow_run` runs - # there, not on the pull request); the box on the pull request itself is the - # deployment created at the end, against the fork's head sha. + # The box on the run, which is the only place the URL would otherwise not + # appear. Required reviewers on this environment would work, and would be a + # second approval on top of the label, in the Actions tab rather than on the + # pull request -- the label is the gate. environment: name: preview url: ${{ steps.preview.outputs.url }} @@ -70,6 +78,38 @@ jobs: # No token, no preview: a checkout of this repository that never set them PREVIEW: ${{ secrets.VERCEL_TOKEN != '' }} steps: + # Off first, and not at the end: a failed deploy leaves a button that can + # be pressed again rather than a label to remove by hand. + - uses: actions/github-script@v9 + if: ${{ github.event_name == 'pull_request_target' }} + continue-on-error: true # it is the button, not the deploy + with: + script: | + await github.rest.issues.removeLabel({ + ...context.repo, + issue_number: context.payload.pull_request.number, + name: "preview", + }); + + # Which commit, and which run built it. Both come from the event -- the + # only thing a pull request chooses here is its own head, and the run has + # to be a CI run of this repository on that exact sha. + - id: what + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SHA: ${{ github.event.workflow_run.head_sha || github.event.pull_request.head.sha }} + RUN_ID: ${{ github.event.workflow_run.id }} + run: | + if [ -z "$RUN_ID" ]; then + # The label can land before CI has finished, in which case the + # artifact is not there yet and the download below says so: press + # it again once the build is green. + RUN_ID=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$SHA&event=pull_request" \ + --jq '[.workflow_runs[] | select(.name == "CI")] | first | .id') + fi + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT" + # No artifact is a build that failed, and the run goes red here rather # than on the pull request -- where `preview-job` is already red for it. - uses: actions/download-artifact@v8 @@ -77,7 +117,7 @@ jobs: with: name: preview path: artifact - run-id: ${{ github.event.workflow_run.id }} + run-id: ${{ steps.what.outputs.run_id }} github-token: ${{ secrets.GITHUB_TOKEN }} - id: preview @@ -121,11 +161,12 @@ jobs: if: ${{ env.PREVIEW == 'true' }} env: URL: ${{ steps.preview.outputs.url }} + SHA: ${{ steps.what.outputs.sha }} with: script: | const { data } = await github.rest.repos.createDeployment({ ...context.repo, - ref: context.payload.workflow_run.head_sha, + ref: process.env.SHA, environment: "preview", transient_environment: true, // superseded by the next one auto_merge: false, // deploy that ref, not a merge of the base into it From 2ac0d25ef82e1c691653a29222b150dfacc56afd Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Sat, 15 Aug 2026 17:55:38 +0200 Subject: [PATCH 4/6] The build is its own workflow, so the preview does not wait on the tests `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 #202 undone by the back door, three months after it was measured and fixed. Measured again, on #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 --- .github/workflows/ci.yml | 82 ---------------------------- .github/workflows/preview-build.yml | 85 +++++++++++++++++++++++++++++ .github/workflows/preview.yml | 16 +++--- 3 files changed, 93 insertions(+), 90 deletions(-) create mode 100644 .github/workflows/preview-build.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63bbbeaa..3fe9b994 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -248,88 +248,6 @@ jobs: with: path: ./out${{ steps.configurepages.outputs.base_path }} - # - # Preview (only for pull requests) - # - # No `needs`, deliberately. `test` dependsOn `build2`, so a shard builds the - # examples it is about to test -- and this job used to wait for all eight of - # them, for the warm cache that made its own `pnpm build` a cache hit. It was - # waiting on the wrong half. Measured on #200: the build it needs took 31s of - # that wait, the tests it also waited for took 3m16. On a pull request that - # touches many examples the same two numbers are ~3m30 and ~20m -- 170 - # examples at ~60s of software rendering is what makes the sweep long, and - # building them was never the expensive part. - # - # So it builds all of them itself, at t=0. The preview lands at ~2min rather - # than 5m21. The turbo cache is shared and written as each shard's tasks - # finish, so what this actually rebuilds is whatever the shards have not - # reached yet: the duplicated work is a ceiling, not a bill -- and on a public - # repository the runner it occupies is free. - # - # What it gives up: an example whose test is red now reaches the preview. - # Which is what a preview is for -- `chromatic-job` publishes on `always()` - # for the same reason -- and merging is still gated on the tests. - # - # It builds, and it does not deploy. This workflow runs on `pull_request`, - # which for a fork's pull request means a read-only token and no access to - # the repo secrets -- and that is the property worth keeping, because - # everything this job runs is the pull request's own code (`pnpm install` - # alone is arbitrary `postinstall` scripts). So the deployment moved to - # `preview.yml`, which `workflow_run` runs back in this repository's context - # with the Vercel token, and which never executes any of it: it unpacks the - # artifact below and uploads it. The artifact is the boundary between the two. - # - # What it costs: a fork's pull request now pays this ~2min build where it used - # to skip it -- on a public repository, a free runner. - # - preview-job: - if: ${{ github.event_name == 'pull_request' }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - id: configurepages - uses: actions/configure-pages@v5 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 - with: - node-version-file: ".nvmrc" - cache: "pnpm" - - run: pnpm install --frozen-lockfile - - uses: rharkor/caching-for-turbo@v2.5.1 - - # The same `BASE_PATH` as the Pages build, and not for tidiness: - # `BASE_PATH` is a `globalEnv`, and it really does change every example's - # `dist` (vite's `--base`). Building the preview without it gave every - # `build2` a different hash from the one the rest of this workflow - # produces -- 167 rebuilds, ~3.5min, and never a cache hit across runs - # either, since `main` never produces those hashes and a pull request - # cannot read another one's cache. With it, the hashes line up, and what - # is actually built here is what the shards have not cached yet. - # - # What it costs: the preview is served from `${BASE_PATH}`, not the - # domain root, since the pages link to each other by absolute path. That - # is the production layout, which is what a preview should be showing. - - run: pnpm build - env: - BASE_PATH: ${{ steps.configurepages.outputs.base_path }} - # The production origin, deliberately: the URLs are canonical - # links, and a preview that is "the production layout" should carry - # them too -- besides, a different value is a different `build2` - # hash, and these have to be the hashes everything else produces. - BASE_URL: ${{ steps.configurepages.outputs.base_url }} - - # `base_path` travels with the build because the job that deploys it - # cannot recompute it: `configure-pages` reads the Pages settings, and - # asking for that permission in a workflow that holds the Vercel token - # buys nothing. `preview.yml` validates the value before it uses it. - - run: echo "${{ steps.configurepages.outputs.base_path }}" > base_path - - uses: actions/upload-artifact@v7 - with: - name: preview - path: | - out - base_path - deploy-job: # only for pushes on main if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }} diff --git a/.github/workflows/preview-build.yml b/.github/workflows/preview-build.yml new file mode 100644 index 00000000..a7687be3 --- /dev/null +++ b/.github/workflows/preview-build.yml @@ -0,0 +1,85 @@ +# +# Preview build (only for pull requests) +# +# Builds the preview and uploads it. `preview.yml` deploys it, and the two are +# separate workflows for two separate reasons. +# +# The first is trust. This one runs on `pull_request`, which for a fork's pull +# request means a read-only token and no access to the repo secrets -- and that +# is the property worth keeping, because everything here is the pull request's +# own code (`pnpm install` alone is arbitrary `postinstall` scripts). The deploy +# runs back in this repository's context with the Vercel token, and never +# executes any of it: it unpacks the artifact below. The artifact is the +# boundary between the two. +# +# The second is when. `workflow_run` fires when a whole run finishes, not when +# a job does, so leaving this inside `ci.yml` would have put the preview behind +# the eight test shards -- #202 undone by the back door. Measured on #220: this +# job takes 1.3min, the run it was part of takes 6, and ~20 on a pull request +# that touches many examples. Its own workflow finishes when it finishes. +# +# Nothing about the build itself moved: same runner, same install, same turbo +# and pnpm caches (the Actions cache is scoped by repository and branch, never +# by workflow), same `BASE_PATH`, so the same `build2` hashes as everywhere +# else. Permissions are `ci.yml`'s, verbatim, for the same reason. +# +name: Preview build + +on: + pull_request: {} + +permissions: + contents: read + pages: write + id-token: write + +env: + TURBO_TELEMETRY_DISABLED: 1 + +jobs: + preview-job: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - id: configurepages + uses: actions/configure-pages@v5 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version-file: ".nvmrc" + cache: "pnpm" + - run: pnpm install --frozen-lockfile + - uses: rharkor/caching-for-turbo@v2.5.1 + + # The same `BASE_PATH` as the Pages build, and not for tidiness: + # `BASE_PATH` is a `globalEnv`, and it really does change every example's + # `dist` (vite's `--base`). Building the preview without it gave every + # `build2` a different hash from the one `ci.yml` and the shards + # produce -- 167 rebuilds, ~3.5min, and never a cache hit across runs + # either, since `main` never produces those hashes and a pull request + # cannot read another one's cache. With it, the hashes line up, and what + # is actually built here is what the shards have not cached yet. + # + # What it costs: the preview is served from `${BASE_PATH}`, not the + # domain root, since the pages link to each other by absolute path. That + # is the production layout, which is what a preview should be showing. + - run: pnpm build + env: + BASE_PATH: ${{ steps.configurepages.outputs.base_path }} + # The production origin, deliberately: the URLs are canonical + # links, and a preview that is "the production layout" should carry + # them too -- besides, a different value is a different `build2` + # hash, and these have to be the hashes everything else produces. + BASE_URL: ${{ steps.configurepages.outputs.base_url }} + + # `base_path` travels with the build because the job that deploys it + # cannot recompute it: `configure-pages` reads the Pages settings, and + # asking for that permission in a workflow that holds the Vercel token + # buys nothing. `preview.yml` validates the value before it uses it. + - run: echo "${{ steps.configurepages.outputs.base_path }}" > base_path + - uses: actions/upload-artifact@v7 + with: + name: preview + path: | + out + base_path diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 2376d536..b3955df0 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -25,9 +25,9 @@ # Two doors in, and which one a pull request gets is the only difference the # fork makes: # -# - a branch on this repository -- `workflow_run`, as soon as CI has built -# it. Nobody approves their own colleague's preview, and that is how this -# behaved before the split. +# - a branch on this repository -- `workflow_run`, as soon as +# `preview-build.yml` has built it. Nobody approves a colleague's +# preview, and that is how this behaved before the split. # # - a fork -- nothing, until a maintainer puts the `preview` label on the # pull request. That label is the button: adding one needs triage rights @@ -44,7 +44,7 @@ name: Preview on: workflow_run: - workflows: [CI] + workflows: [Preview build] types: [completed] pull_request_target: types: [labeled] @@ -101,11 +101,11 @@ jobs: RUN_ID: ${{ github.event.workflow_run.id }} run: | if [ -z "$RUN_ID" ]; then - # The label can land before CI has finished, in which case the - # artifact is not there yet and the download below says so: press - # it again once the build is green. + # The label can land before the build has finished, in which + # case the artifact is not there yet and the download below + # says so: press it again once the build is green. RUN_ID=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$SHA&event=pull_request" \ - --jq '[.workflow_runs[] | select(.name == "CI")] | first | .id') + --jq '[.workflow_runs[] | select(.name == "Preview build")] | first | .id') fi echo "sha=$SHA" >> "$GITHUB_OUTPUT" echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT" From b4c02e64823bdf57dc326d83070e142816452320 Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Sat, 15 Aug 2026 17:59:14 +0200 Subject: [PATCH 5/6] Minimal: the label is the only new door 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 #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 --- .github/workflows/ci.yml | 124 +++++++++++++++++++++++++++ .github/workflows/preview-build.yml | 85 ------------------- .github/workflows/preview.yml | 126 +++++++++++----------------- 3 files changed, 173 insertions(+), 162 deletions(-) delete mode 100644 .github/workflows/preview-build.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fe9b994..2b04d51e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -248,6 +248,130 @@ jobs: with: path: ./out${{ steps.configurepages.outputs.base_path }} + # + # Preview (only for pull requests) + # + # No `needs`, deliberately. `test` dependsOn `build2`, so a shard builds the + # examples it is about to test -- and this job used to wait for all eight of + # them, for the warm cache that made its own `pnpm build` a cache hit. It was + # waiting on the wrong half. Measured on #200: the build it needs took 31s of + # that wait, the tests it also waited for took 3m16. On a pull request that + # touches many examples the same two numbers are ~3m30 and ~20m -- 170 + # examples at ~60s of software rendering is what makes the sweep long, and + # building them was never the expensive part. + # + # So it builds all of them itself, at t=0. The preview lands at ~2min rather + # than 5m21. The turbo cache is shared and written as each shard's tasks + # finish, so what this actually rebuilds is whatever the shards have not + # reached yet: the duplicated work is a ceiling, not a bill -- and on a public + # repository the runner it occupies is free. + # + # What it gives up: an example whose test is red now reaches the preview. + # Which is what a preview is for -- `chromatic-job` publishes on `always()` + # for the same reason -- and merging is still gated on the tests. + # + preview-job: + if: ${{ github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + env: + # No token, no deploy *from here*: that is a fork's pull request, which + # cannot read the repo secrets, or a checkout of this repo that never set + # them. It still builds -- the artifact at the bottom is how that build + # reaches `preview.yml`, which has the token and does not run this code. + PREVIEW: ${{ secrets.VERCEL_TOKEN != '' }} + steps: + - uses: actions/checkout@v7 + - id: configurepages + uses: actions/configure-pages@v5 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version-file: ".nvmrc" + cache: "pnpm" + - run: pnpm install --frozen-lockfile + - uses: rharkor/caching-for-turbo@v2.5.1 + + # The same `BASE_PATH` as the Pages build, and not for tidiness: + # `BASE_PATH` is a `globalEnv`, and it really does change every example's + # `dist` (vite's `--base`). Building the preview without it gave every + # `build2` a different hash from the one the rest of this workflow + # produces -- 167 rebuilds, ~3.5min, and never a cache hit across runs + # either, since `main` never produces those hashes and a pull request + # cannot read another one's cache. With it, the hashes line up, and what + # is actually built here is what the shards have not cached yet. + # + # What it costs: the preview is served from `${BASE_PATH}`, not the + # domain root, since the pages link to each other by absolute path. That + # is the production layout, which is what a preview should be showing. + - run: pnpm build + env: + BASE_PATH: ${{ steps.configurepages.outputs.base_path }} + # The production origin, deliberately: the URLs are canonical + # links, and a preview that is "the production layout" should carry + # them too -- besides, a different value is a different `build2` + # hash, and these have to be the hashes everything else produces. + BASE_URL: ${{ steps.configurepages.outputs.base_url }} + - id: preview + if: ${{ env.PREVIEW == 'true' }} + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + BASE_PATH: ${{ steps.configurepages.outputs.base_path }} + run: | + mkdir -p .vercel/output + # `out`, not `out${BASE_PATH}`: the site lives one level down, and the + # static root has to be the level its absolute links resolve against + mv out .vercel/output/static + # `cleanUrls`: the website exports flat `.html` pages and links + # to them without the extension. The route sends the bare deployment + # URL -- the one Vercel prints, and the one anyone trims to -- to the + # home page, which now sits under the base path rather than at `/`. + printf '{"version":3,"cleanUrls":true,"routes":[{"src":"/","status":308,"headers":{"Location":"%s"}}]}' \ + "${BASE_PATH:-/}" > .vercel/output/config.json + # `--archive`: one tarball, not 2.4k file uploads + url=$(pnpm dlx vercel@latest deploy --prebuilt --archive=tgz --token="$VERCEL_TOKEN") + echo "url=$url$BASE_PATH" >> "$GITHUB_OUTPUT" + # The pull request's "View deployment" box, which Vercel's git integration + # would have filled had we let it deploy + - uses: actions/github-script@v9 + if: ${{ env.PREVIEW == 'true' }} + env: + URL: ${{ steps.preview.outputs.url }} + with: + script: | + const { data } = await github.rest.repos.createDeployment({ + ...context.repo, + ref: context.payload.pull_request.head.sha, + environment: "preview", + transient_environment: true, // superseded by the next one + auto_merge: false, // deploy that ref, not a merge of the base into it + required_contexts: [], // the checks that would gate it are the ones running this + }); + await github.rest.repos.createDeploymentStatus({ + ...context.repo, + deployment_id: data.id, + state: "success", + environment_url: process.env.URL, + }); + + # A fork's build stops here otherwise, which is the whole of what it used + # to do: skip. `preview.yml` picks this up when a maintainer asks it to, + # and `base_path` travels with it because that workflow cannot recompute + # it without a Pages permission it has no other use for. + - if: ${{ env.PREVIEW != 'true' }} + run: echo "${{ steps.configurepages.outputs.base_path }}" > base_path + - if: ${{ env.PREVIEW != 'true' }} + uses: actions/upload-artifact@v7 + with: + name: preview + path: | + out + base_path + # The label is pressed while the pull request is being looked at, or + # not at all. 90 days of 235MB tarballs is the other option. + retention-days: 7 + deploy-job: # only for pushes on main if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }} diff --git a/.github/workflows/preview-build.yml b/.github/workflows/preview-build.yml deleted file mode 100644 index a7687be3..00000000 --- a/.github/workflows/preview-build.yml +++ /dev/null @@ -1,85 +0,0 @@ -# -# Preview build (only for pull requests) -# -# Builds the preview and uploads it. `preview.yml` deploys it, and the two are -# separate workflows for two separate reasons. -# -# The first is trust. This one runs on `pull_request`, which for a fork's pull -# request means a read-only token and no access to the repo secrets -- and that -# is the property worth keeping, because everything here is the pull request's -# own code (`pnpm install` alone is arbitrary `postinstall` scripts). The deploy -# runs back in this repository's context with the Vercel token, and never -# executes any of it: it unpacks the artifact below. The artifact is the -# boundary between the two. -# -# The second is when. `workflow_run` fires when a whole run finishes, not when -# a job does, so leaving this inside `ci.yml` would have put the preview behind -# the eight test shards -- #202 undone by the back door. Measured on #220: this -# job takes 1.3min, the run it was part of takes 6, and ~20 on a pull request -# that touches many examples. Its own workflow finishes when it finishes. -# -# Nothing about the build itself moved: same runner, same install, same turbo -# and pnpm caches (the Actions cache is scoped by repository and branch, never -# by workflow), same `BASE_PATH`, so the same `build2` hashes as everywhere -# else. Permissions are `ci.yml`'s, verbatim, for the same reason. -# -name: Preview build - -on: - pull_request: {} - -permissions: - contents: read - pages: write - id-token: write - -env: - TURBO_TELEMETRY_DISABLED: 1 - -jobs: - preview-job: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - id: configurepages - uses: actions/configure-pages@v5 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 - with: - node-version-file: ".nvmrc" - cache: "pnpm" - - run: pnpm install --frozen-lockfile - - uses: rharkor/caching-for-turbo@v2.5.1 - - # The same `BASE_PATH` as the Pages build, and not for tidiness: - # `BASE_PATH` is a `globalEnv`, and it really does change every example's - # `dist` (vite's `--base`). Building the preview without it gave every - # `build2` a different hash from the one `ci.yml` and the shards - # produce -- 167 rebuilds, ~3.5min, and never a cache hit across runs - # either, since `main` never produces those hashes and a pull request - # cannot read another one's cache. With it, the hashes line up, and what - # is actually built here is what the shards have not cached yet. - # - # What it costs: the preview is served from `${BASE_PATH}`, not the - # domain root, since the pages link to each other by absolute path. That - # is the production layout, which is what a preview should be showing. - - run: pnpm build - env: - BASE_PATH: ${{ steps.configurepages.outputs.base_path }} - # The production origin, deliberately: the URLs are canonical - # links, and a preview that is "the production layout" should carry - # them too -- besides, a different value is a different `build2` - # hash, and these have to be the hashes everything else produces. - BASE_URL: ${{ steps.configurepages.outputs.base_url }} - - # `base_path` travels with the build because the job that deploys it - # cannot recompute it: `configure-pages` reads the Pages settings, and - # asking for that permission in a workflow that holds the Vercel token - # buys nothing. `preview.yml` validates the value before it uses it. - - run: echo "${{ steps.configurepages.outputs.base_path }}" > base_path - - uses: actions/upload-artifact@v7 - with: - name: preview - path: | - out - base_path diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index b3955df0..1490b2f9 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -1,57 +1,43 @@ # -# Preview +# Preview (only for a fork's pull request, and only on request) # -# The half of `ci.yml`'s `preview-job` that needs the Vercel token, split off -# so that a fork's pull request can have a preview at all. +# `ci.yml`'s `preview-job` deploys its own build and always has. It cannot do +# that for a fork: `pull_request` gives a fork's run a read-only token and no +# secrets, on purpose, because everything that run executes is the pull +# request's code (`pnpm install` alone is arbitrary `postinstall` scripts). +# That is the property being kept, not an obstacle being routed around -- so +# `preview-job` builds and stops, leaving the build in an artifact, and this +# workflow deploys it from here, where the token is. # -# `pull_request` gives a fork's run a read-only token and no secrets, on -# purpose: everything it runs is the pull request's code. So the deploy happens -# here instead, in a workflow that runs in this repository, with the secrets -- -# and that is only safe for as long as it runs none of that code. It does not: -# no checkout of the head, no `pnpm install`, no build. It downloads what -# `ci.yml` built and uploads it. +# It is safe for exactly as long as it runs none of that code, and it runs +# none: no checkout of the head, no install, no build. It downloads the +# artifact and uploads it. That artifact is the boundary, and everything inside +# it is untrusted input -- hence the two precautions below, the Build Output +# API's own `config.json` written here rather than taken from it, and the +# `base_path` check. # -# The artifact is the boundary, and everything inside it is untrusted input. -# Two consequences, both of them below: the Build Output API's own directories -# (`functions/`, `config.json`) are written here and never taken from the -# artifact -- otherwise a pull request could put serverless functions on this -# account -- and `base_path`, which reaches a `Location` header, is checked -# before it is used. +# 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 is left for the fork to reach: static files it wrote, on a -# `*.vercel.app` URL, in a project that has no environment variables and no -# domain of ours. Which is what a preview of its own code is. +# The button is the `preview` label. Applying a label needs triage rights on +# this repository, so the authorization is GitHub's own and not a check we +# could write wrong -- and the contributor cannot label their own pull request. +# The label comes straight back off, so the next click is another deploy. # -# Two doors in, and which one a pull request gets is the only difference the -# fork makes: -# -# - a branch on this repository -- `workflow_run`, as soon as -# `preview-build.yml` has built it. Nobody approves a colleague's -# preview, and that is how this behaved before the split. -# -# - a fork -- nothing, until a maintainer puts the `preview` label on the -# pull request. That label is the button: adding one needs triage rights -# here, which is exactly the set of people who should be deciding, and the -# contributor cannot add it to their own. It comes straight back off, so -# the next click is another deploy. -# -# `pull_request_target` is what makes that label event able to see the secrets. -# The reason it is usually a footgun is a checkout of the pull request's head -# followed by anything that executes it -- which is the one thing this file is -# built not to do. +# `pull_request_target` is what lets that event see the secrets. It is the +# usual footgun when a workflow checks out the pull request's head and then +# executes it, which is the one thing this file is built not to do. # name: Preview on: - workflow_run: - workflows: [Preview build] - types: [completed] pull_request_target: types: [labeled] # Deliberately not the repository defaults: this workflow holds the token, so -# it gets the permissions it uses and nothing else. `actions: read` reads -# another run's artifact, `pull-requests: write` takes the label back off. +# it gets the three permissions it uses and nothing else. `actions: read` reads +# the artifact off another run, `pull-requests: write` takes the label off. permissions: actions: read deployments: write @@ -59,29 +45,20 @@ permissions: jobs: preview-job: + # A pull request from a branch of this repository already has its preview, + # deployed by `preview-job` at the end of its own build, and never uploaded + # an artifact for this one to find. if: >- - ${{ (github.event_name == 'workflow_run' - && github.event.workflow_run.event == 'pull_request' - && github.event.workflow_run.head_repository.full_name == github.repository) - || (github.event_name == 'pull_request_target' - && github.event.label.name == 'preview') }} + ${{ github.event.label.name == 'preview' + && github.event.pull_request.head.repo.full_name != github.repository }} runs-on: ubuntu-latest - # The box on the run, which is the only place the URL would otherwise not - # appear. Required reviewers on this environment would work, and would be a - # second approval on top of the label, in the Actions tab rather than on the - # pull request -- the label is the gate. - environment: - name: preview - url: ${{ steps.preview.outputs.url }} - env: - # No token, no preview: a checkout of this repository that never set them + # No token, no preview: a checkout of this repo that never set them PREVIEW: ${{ secrets.VERCEL_TOKEN != '' }} steps: - # Off first, and not at the end: a failed deploy leaves a button that can - # be pressed again rather than a label to remove by hand. + # Off first, and not at the end: a deploy that fails leaves a button that + # can be pressed again rather than a label to clear by hand. - uses: actions/github-script@v9 - if: ${{ github.event_name == 'pull_request_target' }} continue-on-error: true # it is the button, not the deploy with: script: | @@ -91,27 +68,21 @@ jobs: name: "preview", }); - # Which commit, and which run built it. Both come from the event -- the - # only thing a pull request chooses here is its own head, and the run has - # to be a CI run of this repository on that exact sha. + # Which run built this commit. The sha is the event's, and the run has to + # be a CI run of this repository on that exact sha -- a fork chooses what + # is in its own head commit, and nothing else here. - id: what env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SHA: ${{ github.event.workflow_run.head_sha || github.event.pull_request.head.sha }} - RUN_ID: ${{ github.event.workflow_run.id }} + SHA: ${{ github.event.pull_request.head.sha }} run: | - if [ -z "$RUN_ID" ]; then - # The label can land before the build has finished, in which - # case the artifact is not there yet and the download below - # says so: press it again once the build is green. - RUN_ID=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$SHA&event=pull_request" \ - --jq '[.workflow_runs[] | select(.name == "Preview build")] | first | .id') - fi - echo "sha=$SHA" >> "$GITHUB_OUTPUT" - echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT" + # The label can land before the build has finished, in which case the + # artifact is not there yet and the download below says so: press it + # again once `preview-job` is green. + run_id=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$SHA&event=pull_request" \ + --jq '[.workflow_runs[] | select(.name == "CI")] | first | .id') + echo "run_id=$run_id" >> "$GITHUB_OUTPUT" - # No artifact is a build that failed, and the run goes red here rather - # than on the pull request -- where `preview-job` is already red for it. - uses: actions/download-artifact@v8 if: ${{ env.PREVIEW == 'true' }} with: @@ -120,6 +91,8 @@ jobs: run-id: ${{ steps.what.outputs.run_id }} github-token: ${{ secrets.GITHUB_TOKEN }} + # From here down, `ci.yml`'s deploy step, verbatim apart from where the + # `out` directory comes from - id: preview if: ${{ env.PREVIEW == 'true' }} env: @@ -155,18 +128,17 @@ jobs: echo "url=$url$base_path" >> "$GITHUB_OUTPUT" # The pull request's "View deployment" box, which Vercel's git integration - # would have filled had we let it deploy. The sha is the event's, not the - # artifact's: a fork's head commit is reachable here as `refs/pull/N/head`. + # would have filled had we let it deploy. The fork's head commit is + # reachable from here as `refs/pull/N/head`. - uses: actions/github-script@v9 if: ${{ env.PREVIEW == 'true' }} env: URL: ${{ steps.preview.outputs.url }} - SHA: ${{ steps.what.outputs.sha }} with: script: | const { data } = await github.rest.repos.createDeployment({ ...context.repo, - ref: process.env.SHA, + ref: context.payload.pull_request.head.sha, environment: "preview", transient_environment: true, // superseded by the next one auto_merge: false, // deploy that ref, not a merge of the base into it From c1d52ba772c288f758e5d520efd6709d8affe069 Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Sat, 15 Aug 2026 20:40:31 +0200 Subject: [PATCH 6/6] Three things this did not need - taking the label back off. It was there so the button could be pressed twice; removing and re-adding it is two clicks and no code, and it costs a `pull-requests: write` on the workflow that holds the Vercel token. - the `PREVIEW` guard. `preview-job` needs it because it runs on every pull request. This one runs when somebody with triage rights presses a button, and the only checkout of this repo where that happens without a Vercel token is one nobody is pressing it in. - six lines validating `base_path` against a leading `//`. `basename` is one line and gives a stronger answer: whatever the artifact says, what comes out is a single path segment on the deployment's own host. `//evil.com` becomes `/evil.com`. Also `.workflow_runs[0]`, since `ci.yml` is the only workflow on `pull_request` for that sha, and a comment block cut roughly in half. 64 lines of YAML where there were 100-odd, and 18 in `ci.yml`. What stays is the hop that cannot go: a fork's build cannot deploy itself, so it lands in an artifact and something with the token picks it up. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 17 ++--- .github/workflows/preview.yml | 121 +++++++++++----------------------- 2 files changed, 44 insertions(+), 94 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b04d51e..5d74bfad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -274,10 +274,9 @@ jobs: if: ${{ github.event_name == 'pull_request' }} runs-on: ubuntu-latest env: - # No token, no deploy *from here*: that is a fork's pull request, which + # No token, no deploy from here: that is a fork's pull request, which # cannot read the repo secrets, or a checkout of this repo that never set - # them. It still builds -- the artifact at the bottom is how that build - # reaches `preview.yml`, which has the token and does not run this code. + # them. It still builds; the artifact at the bottom is where that goes. PREVIEW: ${{ secrets.VERCEL_TOKEN != '' }} steps: - uses: actions/checkout@v7 @@ -355,10 +354,10 @@ jobs: environment_url: process.env.URL, }); - # A fork's build stops here otherwise, which is the whole of what it used - # to do: skip. `preview.yml` picks this up when a maintainer asks it to, - # and `base_path` travels with it because that workflow cannot recompute - # it without a Pages permission it has no other use for. + # Otherwise the build stops here, which is all a fork's used to do. + # `preview.yml` deploys it when a maintainer asks, and needs `base_path` + # with it: `configure-pages` reads the Pages settings, and that is a + # permission a workflow holding the Vercel token has no other use for. - if: ${{ env.PREVIEW != 'true' }} run: echo "${{ steps.configurepages.outputs.base_path }}" > base_path - if: ${{ env.PREVIEW != 'true' }} @@ -368,9 +367,7 @@ jobs: path: | out base_path - # The label is pressed while the pull request is being looked at, or - # not at all. 90 days of 235MB tarballs is the other option. - retention-days: 7 + retention-days: 7 # pressed while it is being looked at, or not at all deploy-job: # only for pushes on main diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 1490b2f9..5712f744 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -1,33 +1,25 @@ # -# Preview (only for a fork's pull request, and only on request) +# Preview (a fork's pull request, on request) # -# `ci.yml`'s `preview-job` deploys its own build and always has. It cannot do -# that for a fork: `pull_request` gives a fork's run a read-only token and no -# secrets, on purpose, because everything that run executes is the pull -# request's code (`pnpm install` alone is arbitrary `postinstall` scripts). -# That is the property being kept, not an obstacle being routed around -- so -# `preview-job` builds and stops, leaving the build in an artifact, and this -# workflow deploys it from here, where the token is. +# `ci.yml`'s `preview-job` deploys its own build, and cannot do that for a +# fork: a `pull_request` run from a fork gets no secrets, on purpose, because +# everything it executes is that pull request's code (`pnpm install` alone is +# arbitrary `postinstall` scripts). So it builds and stops, leaving `out` in an +# artifact -- and this workflow, which has the token, uploads that. It is safe +# for exactly as long as it runs none of that code, and it runs none: no +# checkout of the head, no install, no build. # -# It is safe for exactly as long as it runs none of that code, and it runs -# none: no checkout of the head, no install, no build. It downloads the -# artifact and uploads it. That artifact is the boundary, and everything inside -# it is untrusted input -- hence the two precautions below, the Build Output -# API's own `config.json` written here rather than taken from it, and the -# `base_path` check. +# So the artifact is the boundary, and it is untrusted input. `config.json` is +# written here rather than taken from it, or a pull request could put +# serverless functions on the account, and `out` can only become `static/`. +# What a fork reaches in the end is static files it wrote, on a `*.vercel.app` +# URL, in a project with no environment variables and no domain of ours. # -# 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. -# -# The button is the `preview` label. Applying a label needs triage rights on -# this repository, so the authorization is GitHub's own and not a check we -# could write wrong -- and the contributor cannot label their own pull request. -# The label comes straight back off, so the next click is another deploy. -# -# `pull_request_target` is what lets that event see the secrets. It is the -# usual footgun when a workflow checks out the pull request's head and then -# executes it, which is the one thing this file is built not to do. +# The button is the `preview` label. Applying one needs triage rights here, so +# the authorization is GitHub's own and the contributor cannot label their own +# pull request. `pull_request_target` is what lets that event see the secrets; +# the footgun there is checking out the head and then running it, which is the +# one thing this file does not do. # name: Preview @@ -35,103 +27,64 @@ on: pull_request_target: types: [labeled] -# Deliberately not the repository defaults: this workflow holds the token, so -# it gets the three permissions it uses and nothing else. `actions: read` reads -# the artifact off another run, `pull-requests: write` takes the label off. permissions: - actions: read - deployments: write - pull-requests: write + actions: read # the artifact, off another run + deployments: write # the "View deployment" box jobs: preview-job: - # A pull request from a branch of this repository already has its preview, - # deployed by `preview-job` at the end of its own build, and never uploaded - # an artifact for this one to find. + # A branch of this repository deployed its own preview an hour ago and + # uploaded no artifact for this to find if: >- ${{ github.event.label.name == 'preview' && github.event.pull_request.head.repo.full_name != github.repository }} runs-on: ubuntu-latest - env: - # No token, no preview: a checkout of this repo that never set them - PREVIEW: ${{ secrets.VERCEL_TOKEN != '' }} steps: - # Off first, and not at the end: a deploy that fails leaves a button that - # can be pressed again rather than a label to clear by hand. - - uses: actions/github-script@v9 - continue-on-error: true # it is the button, not the deploy - with: - script: | - await github.rest.issues.removeLabel({ - ...context.repo, - issue_number: context.payload.pull_request.number, - name: "preview", - }); - - # Which run built this commit. The sha is the event's, and the run has to - # be a CI run of this repository on that exact sha -- a fork chooses what - # is in its own head commit, and nothing else here. - - id: what + # The run that built this commit. Pressed before `preview-job` has + # finished, the download below says so and the label can be pressed again. + - id: ci env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SHA: ${{ github.event.pull_request.head.sha }} run: | - # The label can land before the build has finished, in which case the - # artifact is not there yet and the download below says so: press it - # again once `preview-job` is green. - run_id=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$SHA&event=pull_request" \ - --jq '[.workflow_runs[] | select(.name == "CI")] | first | .id') - echo "run_id=$run_id" >> "$GITHUB_OUTPUT" - + gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$SHA&event=pull_request" \ + --jq '"run_id=\(.workflow_runs[0].id)"' >> "$GITHUB_OUTPUT" - uses: actions/download-artifact@v8 - if: ${{ env.PREVIEW == 'true' }} with: name: preview path: artifact - run-id: ${{ steps.what.outputs.run_id }} + run-id: ${{ steps.ci.outputs.run_id }} github-token: ${{ secrets.GITHUB_TOKEN }} - # From here down, `ci.yml`'s deploy step, verbatim apart from where the - # `out` directory comes from + # `ci.yml`'s deploy step, less the build that used to sit in front of it - id: preview - if: ${{ env.PREVIEW == 'true' }} env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} run: | - base_path=$(cat artifact/base_path) - # It came out of a job that ran the pull request's code, and it ends - # up in a `Location`. A leading `//` is a URL to somebody else's host, - # which is the whole of what could go wrong here. - case "$base_path" in - //*|*[!A-Za-z0-9._/-]*) echo "refusing base_path: $base_path"; exit 1 ;; - /*|"") ;; - *) echo "refusing base_path: $base_path"; exit 1 ;; - esac - + # `basename`: this was written by a job that ran the pull request's + # code, and it ends up in a `Location`. One path segment is what a + # Pages base path is, and all this can be afterwards -- `//evil.com` + # comes out `/evil.com`, a path on the deployment's own host. + base_path=/$(basename "$(cat artifact/base_path)") mkdir -p .vercel/output # `out`, not `out${base_path}`: the site lives one level down, and the - # static root has to be the level its absolute links resolve against. - # `static/` is also the only thing the artifact is allowed to become - # -- a `functions/` the pull request wrote inside `out` lands under it - # as a file, not as code Vercel would run. + # static root has to be the level its absolute links resolve against mv artifact/out .vercel/output/static # `cleanUrls`: the website exports flat `.html` pages and links # to them without the extension. The route sends the bare deployment # URL -- the one Vercel prints, and the one anyone trims to -- to the # home page, which now sits under the base path rather than at `/`. printf '{"version":3,"cleanUrls":true,"routes":[{"src":"/","status":308,"headers":{"Location":"%s"}}]}' \ - "${base_path:-/}" > .vercel/output/config.json + "$base_path" > .vercel/output/config.json # `--archive`: one tarball, not 2.4k file uploads url=$(npx --yes vercel@latest deploy --prebuilt --archive=tgz --token="$VERCEL_TOKEN") echo "url=$url$base_path" >> "$GITHUB_OUTPUT" # The pull request's "View deployment" box, which Vercel's git integration - # would have filled had we let it deploy. The fork's head commit is - # reachable from here as `refs/pull/N/head`. + # would have filled had we let it deploy - uses: actions/github-script@v9 - if: ${{ env.PREVIEW == 'true' }} env: URL: ${{ steps.preview.outputs.url }} with: