Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,9 @@ jobs:
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.
# 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 where that goes.
PREVIEW: ${{ secrets.VERCEL_TOKEN != '' }}
steps:
- uses: actions/checkout@v7
Expand All @@ -302,7 +303,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 <head> URLs are canonical
Expand Down Expand Up @@ -354,6 +354,21 @@ jobs:
environment_url: process.env.URL,
});

# 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' }}
uses: actions/upload-artifact@v7
with:
name: preview
path: |
out
base_path
retention-days: 7 # pressed while it is being looked at, or not at all

deploy-job:
# only for pushes on main
if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }}
Expand Down
105 changes: 105 additions & 0 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#
# Preview (a fork's pull request, on request)
#
# `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.
#
# 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.
#
# 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

on:
pull_request_target:
types: [labeled]

permissions:
actions: read # the artifact, off another run
deployments: write # the "View deployment" box

jobs:
preview-job:
# 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
steps:
# 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: |
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
with:
name: preview
path: artifact
run-id: ${{ steps.ci.outputs.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}

# `ci.yml`'s deploy step, less the build that used to sit in front of it
- id: preview
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
run: |
# `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
mv artifact/out .vercel/output/static
# `cleanUrls`: the website exports flat `<name>.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
- uses: actions/github-script@v9
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,
});
Loading