diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdf82cd5..5d74bfad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 URLs are canonical @@ -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' }} diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 00000000..5712f744 --- /dev/null +++ b/.github/workflows/preview.yml @@ -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 `.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, + });