diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index ca0f300e..06098528 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -1,15 +1,21 @@ # Workflow 1 of 2 for PR previews. # -# This one runs in the pull request's own context, which means it gets a -# read-only token and no secrets — and, because a `pull_request` run uses the -# workflow file from the pull request itself, a fork can rewrite every line of -# it. So it is deliberately empty. Its only purpose is to finish, which fires -# the `workflow_run` that starts pr-deploy.yml in the base repository's -# context, where the write token and the secrets live. +# Runs in the pull request's own context, which is the only context a fork's +# code may be built in. A `pull_request` run gets a read-only token, no secrets +# and no repository variables, and its workflow file is the fork's own copy — +# so nothing here is trusted with anything, and nothing it produces is trusted +# by pr-deploy.yml either. # -# Nothing it produced would be trustworthy, so it produces nothing: pr-deploy -# works out which pull request it is looking at from the event payload instead. -# test.yml and pr-report.yml pair up the same way, for the same reason. +# That is why the build lives here rather than in the workflow that has the +# secrets. Building in the base repository's context would mean executing a +# fork's dependency tree and vite config in a job holding this repository's +# token, secrets and default-branch cache scope — the "pwn request" shape that +# actions/checkout refuses by default. The settings the build needs are +# committed in frontend/.env.preview instead; none of them is a credential. +# +# pr-deploy.yml picks the artifact up over workflow_run, checks it, and +# publishes it. Which pull request that is, it works out from the event +# payload — never from anything in here. name: 'PR Preview: Build' @@ -19,20 +25,88 @@ on: paths: - 'frontend/**' -permissions: {} +permissions: + contents: read -# No concurrency group on purpose. This job is free, and cancelling it would -# also cancel the deploy or the cleanup it exists to trigger — including the -# cleanup for a pull request that has just been closed, whose preview would -# then sit on gh-pages forever. pr-deploy.yml carries the concurrency group, -# where the expensive work actually is. +# Supersede a build that a newer push has already made stale. A close event +# cancels an in-flight build for the same pull request too, which is the right +# order: the preview is about to be removed either way. +concurrency: + group: pr-preview-build-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: - signal: - name: Signal the deploy workflow + build: + name: Build preview + if: github.event.action != 'closed' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + # Nothing here uses git, and this job builds unreviewed code. + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + + - name: Install dependencies + working-directory: ./frontend + run: | + # --ignore-scripts: a dependency's install hooks have no business + # running in order to produce a static site. + if [ -f package-lock.json ]; then + npm ci --ignore-scripts + else + npm install --ignore-scripts + fi + + - name: Build + working-directory: ./frontend + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + site_base="$(sed -n 's/^PREVIEW_PUBLIC_BASE=//p' .env.preview | tail -n 1)" + case "$site_base" in + /|/*/) ;; + *) + echo "::error::PREVIEW_PUBLIC_BASE in frontend/.env.preview must open and close with '/' (got '${site_base}')" + exit 1 + ;; + esac + + base="${site_base}pr-preview/pr-${PR_NUMBER}/" + echo "Building for ${base}" + # --mode preview is what loads .env.preview. + npm run build -- --mode preview --base "$base" + + # Recorded so the publish job can refuse a build made for the wrong + # path, instead of publishing a page that loads nothing. + printf '%s' "$base" > dist/.preview-base + + # A CNAME means something only at the site root, and the root belongs + # to the production deploy. + rm -f dist/CNAME + + - name: Upload preview build + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: pr-preview-dist + path: ./frontend/dist + retention-days: 1 + + # A closed pull request has nothing to build, but this workflow still has to + # finish: finishing is what fires the workflow_run that removes the preview. + closed: + name: Signal cleanup + if: github.event.action == 'closed' runs-on: ubuntu-latest steps: - - name: Nothing to build here + - name: Nothing to build run: | - echo "Pull request #${{ github.event.pull_request.number }}: ${{ github.event.action }}." - echo "The preview is built and published by 'PR Preview: Deploy'." + echo "Pull request #${{ github.event.pull_request.number }} is closed." + echo "'PR Preview: Deploy' removes its preview from gh-pages." diff --git a/.github/workflows/pr-deploy.yml b/.github/workflows/pr-deploy.yml index 44705c4c..f25c7d77 100644 --- a/.github/workflows/pr-deploy.yml +++ b/.github/workflows/pr-deploy.yml @@ -1,18 +1,16 @@ # Workflow 2 of 2 for PR previews. # -# Runs in the base repository's context, so it has the secrets the build needs -# and a token that can push to gh-pages and comment on the pull request. Two -# rules follow from that, and the job split below is what enforces them: +# Runs in the base repository's context, so it holds a token that can push to +# gh-pages and comment on the pull request. It therefore never checks out or +# executes the pull request's code: pr-build.yml already built it, in the +# pull request's own context, and this workflow only unpacks that artifact. # -# 1. The job that runs the pull request's code holds no write token. A build -# executes whatever the fork put in its dependency tree and its vite -# config; were that the same job that pushes, it could reach the token -# through $GITHUB_PATH or $GITHUB_ENV and write to the repository. -# 2. The job that holds the write token never checks out the pull request's -# code. It only unpacks the artifact the build produced. -# -# Which pull request this is comes from the event payload, never from anything -# the triggering run produced — see the resolve job. +# Two things arrive here untrusted, and both are treated that way: +# - The artifact, which is whatever a fork's build wrote. Checked before it is +# committed, and only ever written under pr-preview/pr-/. +# - The triggering run's identity. Which pull request this is comes from the +# workflow_run payload, resolved against the API — never from a file the +# other workflow produced, because a fork controls its own copy of it. name: 'PR Preview: Deploy' @@ -33,7 +31,12 @@ concurrency: jobs: resolve: name: Resolve target - if: github.event.workflow_run.event == 'pull_request' + # A failed build has nothing worth publishing, and it already shows up as a + # failed check on the pull request. A closed pull request's run succeeds + # with its build job skipped, so cleanup still gets here. + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest permissions: contents: read @@ -46,19 +49,16 @@ jobs: base_path: ${{ steps.pages.outputs.base_path }} site_url: ${{ steps.pages.outputs.site_url }} steps: - # The base repository, for the scripts. Never the pull request's code. - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - # This repository's own default branch, spelled out. Nothing in - # these three jobs may come from the pull request, and which - # commit checkout would pick by itself on a `workflow_run` is not - # worth relying on when that is what it decides. + # This repository's own default branch, spelled out. Nothing in these + # jobs may come from the pull request, and which commit checkout would + # pick by itself on a `workflow_run` is not worth relying on when that + # is what it decides. ref: ${{ github.event.repository.default_branch }} - # Nothing in this job uses git: the publish script clones with - # GH_TOKEN of its own. Persisting the token into .git/config - # would only leave a write-capable credential lying in the - # workspace while the build runs. + # Nothing here uses git: the publish script clones with a GH_TOKEN of + # its own. persist-credentials: false - name: Resolve Pages location @@ -77,10 +77,9 @@ jobs: const run = context.payload.workflow_run; // Worked out from the event payload alone. A `pull_request` run - // executes the fork's own copy of pr-build.yml, so anything that - // run uploaded is attacker-controlled: a pull request that got to - // name a number could overwrite another one's preview, delete it, - // or post a bot comment on any issue in this repository. + // executes the fork's own copy of pr-build.yml, so a number coming + // from that run could name any pull request in this repository — + // and this workflow can delete previews and post bot comments. const findPullRequest = async () => { // Populated only when head and base are the same repository. if (run.pull_requests?.length) { @@ -119,121 +118,47 @@ jobs: return; } - // A closed pull request loses its preview, merged or not. A deploy - // is for one that is still open, and always at the head it has - // now: this workflow can be reached by a run whose commit has - // since been superseded, and taking the sha from the pull request - // is also what guarantees the commit belongs to it. + // A closed pull request loses its preview, merged or not. core.setOutput('mode', pr.state === 'closed' ? 'cleanup' : 'deploy'); core.setOutput('number', String(pr.number)); core.setOutput('sha', pr.head.sha); core.info(`PR #${pr.number} is ${pr.state}; head ${pr.head.sha.slice(0, 7)}.`); - build: - name: Build preview - needs: resolve - if: needs.resolve.outputs.mode == 'deploy' - runs-on: ubuntu-latest - # Read-only, and deliberately not the job that pushes: the steps below - # execute the pull request's code. - permissions: - contents: read - steps: - - name: Checkout pull request code - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - ref: ${{ needs.resolve.outputs.sha }} - persist-credentials: false - - - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 20 - - - name: Install dependencies - working-directory: ./frontend - run: | - # --ignore-scripts: a dependency's install hooks have no business - # running in order to produce a static site. Vite plugins still - # execute during the build, so this narrows the surface rather than - # closing it — which is why this job holds no write token. - if [ -f package-lock.json ]; then - npm ci --ignore-scripts - else - npm install --ignore-scripts - fi - - - name: Build - working-directory: ./frontend - run: npm run build -- --base "${BASE_PATH}pr-preview/pr-${PR_NUMBER}/" - env: - BASE_PATH: ${{ needs.resolve.outputs.base_path }} - PR_NUMBER: ${{ needs.resolve.outputs.number }} - # Sepolia only, which keeps a preview on testnet without any code - # having to know it is a preview: the app decides which networks it - # supports from which of these addresses is non-empty. Ethereum - # Classic and Polygon are left out for the same reason the production - # deploy leaves them out — both still run the v1 contract, whose ABI - # this frontend no longer matches. See Deployments.md. - VITE_CONTRACT_ADDRESS_11155111: ${{ secrets.VITE_CONTRACT_ADDRESS_11155111 }} - # Reachable here, unlike in pr-build.yml, because this workflow runs - # in the base repository's context. Wallet connection needs it. - VITE_WALLETCONNECT_PROJECT_ID: ${{ secrets.VITE_WALLETCONNECT_PROJECT_ID }} - # Repository variables, as in the production deploy — every VITE_ - # value is published in the bundle. Without these the build inlines - # the http://localhost:3000 relay fallback, which the browser then - # blocks as mixed content on an https preview. - VITE_RELAY_URL: ${{ vars.VITE_RELAY_URL }} - VITE_RELAY_API_KEY: ${{ vars.VITE_RELAY_API_KEY }} - VITE_RELAY_TIMEOUT_MS: ${{ vars.VITE_RELAY_TIMEOUT_MS }} - - # A CNAME anywhere but the site root does nothing, and the root belongs - # to the production deploy. Dropped here so a preview can never be the - # thing that claims or releases the custom domain. - - name: Drop CNAME from the preview build - run: rm -f ./frontend/dist/CNAME - - - name: Upload preview build - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: pr-preview-${{ needs.resolve.outputs.number }} - path: ./frontend/dist - retention-days: 1 - publish: name: Publish preview - needs: [resolve, build] + needs: resolve + if: needs.resolve.outputs.mode == 'deploy' runs-on: ubuntu-latest - # Holds the write token, so it never checks out or runs the pull request's - # code — only this repository's scripts, and the built artifact. permissions: - contents: write - pull-requests: write - issues: write + contents: write # pushes the preview to gh-pages + pull-requests: write # comments the preview URL + issues: write # listComments/createComment go through the issues API + actions: read # reads the build's artifact from the triggering run steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - # This repository's code, as in the resolve job. ref: ${{ github.event.repository.default_branch }} - # Nothing in this job uses git: the publish script clones with - # GH_TOKEN of its own. Persisting the token into .git/config - # would only leave a write-capable credential lying in the - # workspace while the build runs. persist-credentials: false - name: Download preview build uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: pr-preview-${{ needs.resolve.outputs.number }} + name: pr-preview-dist path: preview + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} - name: Check the build before publishing it + env: + EXPECTED_BASE: ${{ needs.resolve.outputs.base_path }}pr-preview/pr-${{ needs.resolve.outputs.number }}/ run: | - # The artifact holds whatever the fork's build wrote, so check the - # few things that would make committing it a bad idea. A symlink - # would go onto gh-pages as a symlink, and a .git entry would confuse - # the publish; a static site needs neither. + set -euo pipefail + + # The artifact holds whatever a fork's build wrote, so check the few + # things that would make committing it a bad idea. A symlink would go + # onto gh-pages as a symlink, and a .git entry would confuse the + # publish; a static site needs neither. if [ -n "$(find preview -type l -print -quit)" ]; then echo "::error::preview build contains symlinks" exit 1 @@ -247,6 +172,20 @@ jobs: exit 1 fi + # The build had to be told the site root, because it runs without a + # token that could ask the Pages API. This job can ask, so it checks — + # a mismatch means every asset would 404 and the preview would come up + # blank, which is worth an error rather than a puzzled contributor. + # Stripped of anything but path characters before being echoed, since + # the file came from the build. + built_base="$(tr -dc 'A-Za-z0-9/._-' < preview/.preview-base 2>/dev/null || true)" + if [ "$built_base" != "$EXPECTED_BASE" ]; then + echo "::error::this build is for '${built_base:-}' but the site serves '${EXPECTED_BASE}'." + echo "::error::set PREVIEW_PUBLIC_BASE=${{ needs.resolve.outputs.base_path }} in frontend/.env.preview" + exit 1 + fi + rm -f preview/.preview-base + - name: Publish to gh-pages env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -290,12 +229,7 @@ jobs: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - # This repository's code, as in the resolve job. ref: ${{ github.event.repository.default_branch }} - # Nothing in this job uses git: the publish script clones with - # GH_TOKEN of its own. Persisting the token into .git/config - # would only leave a write-capable credential lying in the - # workspace while the build runs. persist-credentials: false - name: Remove the preview from gh-pages diff --git a/Deployments.md b/Deployments.md index 1bd469dc..95919fca 100644 --- a/Deployments.md +++ b/Deployments.md @@ -73,7 +73,20 @@ until someone opens the site. Where the API is not an option, set a ### What a preview build gets -Only `VITE_CONTRACT_ADDRESS_11155111`, so a preview reaches Ethereum Sepolia and -nothing else: the app treats any network with a non-empty contract address as -supported, so omitting the rest is all it takes to keep previews on testnet. -Relay settings come from repository variables, the same ones production uses. +`frontend/.env.preview`, loaded by `vite build --mode preview` — committed, and +not from secrets or variables. GitHub passes neither to a workflow triggered by +a pull request from a fork, and a fork's code has to be built somewhere that has +no token worth stealing: `actions/checkout` refuses outright to fetch fork code +into a `workflow_run` job, because such a job holds the base repository's +secrets, cache scope and runner. Nothing in that file is a credential — Vite +inlines every `VITE_` value into the bundle it emits, so all of them are already +readable on the production site. + +It sets `VITE_CONTRACT_ADDRESS_11155111` and no other chain, which is the whole +of what keeps a preview on testnet: the app treats a network as supported +exactly when its contract address is non-empty. + +`PREVIEW_PUBLIC_BASE` in that file is the path Pages serves this site from — +`/` here, `//` on a fork. The build has no token to ask the Pages API +with, so it is told; the publish job does ask, and refuses a build made for the +wrong path rather than serve a page whose every asset 404s. diff --git a/frontend/.env.preview b/frontend/.env.preview new file mode 100644 index 00000000..ab2944fe --- /dev/null +++ b/frontend/.env.preview @@ -0,0 +1,48 @@ +# Build settings for PR preview deployments (`vite build --mode preview`). +# +# Committed, and deliberately so. A preview is built by "PR Preview: Build", +# which runs in the pull request's own context — the only place a fork's code +# may be executed — and GitHub passes neither secrets nor repository variables +# to a workflow triggered by a pull request from a fork. Building somewhere +# that does have them would mean running fork code in a job that holds this +# repository's token, secrets and cache scope, which is the "pwn request" shape +# actions/checkout now refuses by default. +# +# Nothing here is a credential. Vite inlines every VITE_ value into the +# JavaScript it emits, so all of these are already readable by anyone who opens +# the production site — the WalletConnect id below is the same one served from +# chainvoice.stability.nexus today, and is a repository *variable* rather than a +# secret for that reason. If a value ever does need protecting, it cannot live +# in a static site at all: put a proxy in front and inject it there. +# +# Production does not read this file. It builds with `vite build` (mode +# "production") from secrets and variables — see .github/workflows/deploy.yml. + +# Ethereum Sepolia, the only chain running a contract that matches this ABI. +# Leaving every other VITE_CONTRACT_ADDRESS_* unset is the whole of what keeps +# a preview on testnet: the app treats a network as supported exactly when its +# address is non-empty. Ethereum Classic (61) and Polygon (137) still run v1, +# whose payload format and key registry this frontend no longer speaks — see +# Deployments.md. +VITE_CONTRACT_ADDRESS_11155111=0x65eb0ca96f972c5a0cdaa623a5b54650e499df5b + +VITE_WALLETCONNECT_PROJECT_ID=44381d12a1d69d504f874d77a5eff782 + +# The relay previews talk to. Must be https: a preview is served over https, so +# the browser blocks a plain-http relay as mixed content, and an unset value +# would bake in the http://localhost:3000 development fallback. +VITE_RELAY_URL=https://thrubox-server.onrender.com +VITE_RELAY_TIMEOUT_MS=60000 + +# Build tooling only, not app config — no VITE_ prefix, so Vite neither +# exposes it to the client nor inlines it into the bundle. +# Path GitHub Pages serves this repository's site from, with both slashes. The +# preview build appends pr-preview/pr-/ to it. +# +# "/" because of the custom domain. A fork has no custom domain, so its site +# lives at https://.github.io// and this has to become // — +# on a fork of Chainvoice, "/Chainvoice/". Read from here rather than from the +# Pages API because this build holds no token that could ask. The publish job +# does ask, and fails with the value to put here if the two disagree, so a fork +# that forgets gets an error rather than a blank page. +PREVIEW_PUBLIC_BASE=/