From e9a7f8134472e695d907c48ef01954e10a9d6285 Mon Sep 17 00:00:00 2001 From: Atharva0506 Date: Fri, 4 Sep 2026 22:39:16 +0530 Subject: [PATCH] ci(preview): build the preview where the fork's code belongs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions/checkout refuses to fetch fork pull request code into a workflow_run job, so no fork PR could get a preview at all: Refusing to check out fork pull request code from a 'workflow_run' workflow. This workflow runs with the base repository's GITHUB_TOKEN, secrets, default-branch cache scope, and runner access. It is right to refuse. Splitting build and publish into separate jobs took the write token away from the build, but the job still held the secrets, the runner, and the default-branch cache scope that Test and Build restores an npm cache from — a build could have poisoned it for main. So the build moves to pr-build.yml, which already runs in the pull request's own context, and pr-deploy.yml now only downloads that artifact and publishes it. No trusted job executes fork code anywhere. That job gets neither secrets nor repository variables, because GitHub passes neither to a pull request from a fork. It reads frontend/.env.preview instead, committed. Nothing in it is a credential: Vite inlines every VITE_ value into the bundle, so the WalletConnect id there is the same string already served from chainvoice.stability.nexus, and it is a repository variable rather than a secret for that reason. Sepolia stays the only contract address, so previews stay on testnet. The one thing that build cannot work out for itself is the path Pages serves the site from, having no token to ask the API with — PREVIEW_PUBLIC_BASE carries it, and the publish job, which can ask, refuses a build made for a different path instead of publishing a page whose every asset 404s. A fork that forgets to change it gets that error with the value to use. --- .github/workflows/pr-build.yml | 114 ++++++++++++++++---- .github/workflows/pr-deploy.yml | 180 ++++++++++---------------------- Deployments.md | 21 +++- frontend/.env.preview | 48 +++++++++ 4 files changed, 216 insertions(+), 147 deletions(-) create mode 100644 frontend/.env.preview 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=/