From cc6138cad08e2edeca3bf7a70442f78a85644b53 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:31:18 -0700 Subject: [PATCH 1/2] ci(ui-preview): give fork PRs preview deploys via a build-untrusted / deploy-trusted split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fork PRs never got a UI preview: ui-preview.yml was gated `if: head.repo.full_name == github.repository` (forks have no Cloudflare secrets), so it skipped entirely, no Deployment was recorded, and Reviewbot's before/after comment was stuck on a perpetual "Rendering preview…" placeholder. Workers Builds can't fill the gap either — it only builds branches that live in this repo, and a fork PR's commits live in the contributor's fork. Split the pipeline so forks get previews without exposing secrets to untrusted code: - ui-preview.yml (renamed "UI Preview Build", on: pull_request): builds EVERY PR incl forks, WITHOUT secrets (read-only token), and uploads only the built `dist`. - ui-preview-deploy.yml (new, on: workflow_run): runs from the DEFAULT branch (so the definition is trusted, never the fork's) WITH secrets. Downloads the built artifact (never checks out or runs fork source), `wrangler versions upload`s it to a transient workers.dev preview, and records the GitHub Deployment + status Reviewbot reads. Security boundary: the build runs fork code with no secret access; the deploy has secrets but runs no fork code (wrangler only uploads the bundle — fork code executes solely inside the isolated workers.dev preview when the URL is visited). The PR number is resolved from the GitHub-set head_sha (workflow_run.pull_requests is empty for forks), never from fork-supplied data, and the Wrangler config is written trustside so a fork can't control bindings/routes/vars. No Reviewbot change needed — it reads the same `preview/pr-N` Deployment as before. Note: the deploy half only takes effect once this is on the default branch (workflow_run always uses the default-branch definition), so it can't fully run on this PR itself. --- .github/workflows/ui-preview-deploy.yml | 184 ++++++++++++++++++++++++ .github/workflows/ui-preview.yml | 161 ++------------------- 2 files changed, 196 insertions(+), 149 deletions(-) create mode 100644 .github/workflows/ui-preview-deploy.yml diff --git a/.github/workflows/ui-preview-deploy.yml b/.github/workflows/ui-preview-deploy.yml new file mode 100644 index 0000000000..7cb563b038 --- /dev/null +++ b/.github/workflows/ui-preview-deploy.yml @@ -0,0 +1,184 @@ +name: UI Preview Deploy + +# Deploy half of the fork-safe per-PR preview pipeline. Triggered when "UI Preview Build" completes. +# Because it runs on `workflow_run`, GitHub always executes the workflow definition from the DEFAULT +# BRANCH (never the fork's), so it is trusted and may use secrets. It downloads the built `dist` +# artifact (it never checks out or runs PR/fork source — it only uploads the already-built bundle), +# deploys a transient preview version, and records the GitHub Deployment + status that Reviewbot reads +# to render the "after" screenshot. +# +# Security boundary: the BUILD ran fork code with NO secrets; this DEPLOY has secrets but runs NO fork +# code (wrangler only uploads the bundle — fork code executes solely inside the isolated workers.dev +# preview when the URL is later visited). This is what makes fork-PR previews safe. +# +# Required repo secrets: +# CLOUDFLARE_API_TOKEN — token with "Workers Scripts:Edit" on the account +# CLOUDFLARE_ACCOUNT_ID — the Cloudflare account id that owns gittensory-ui + +on: + workflow_run: + workflows: ["UI Preview Build"] + types: [completed] + +permissions: + contents: read + actions: read # download the build artifact from the triggering run + deployments: write # record the preview Deployment Reviewbot reads + +concurrency: + group: ui-preview-deploy-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: true + +jobs: + deploy: + name: Deploy UI preview version + # Only successful build runs that originated from a pull_request (incl forks). A path-skipped or + # failed build still fires workflow_run with a non-success conclusion — ignore those. + if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check Cloudflare secrets + id: cfg + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + if [ -n "$CLOUDFLARE_API_TOKEN" ] && [ -n "$CLOUDFLARE_ACCOUNT_ID" ]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + else + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "::notice::CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID not set — skipping preview deploy (Reviewbot shows before-only)." + fi + + - name: Setup Node + if: steps.cfg.outputs.ready == 'true' + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: 22 + + - name: Install trusted Wrangler + if: steps.cfg.outputs.ready == 'true' + run: npm install --global wrangler@4.95.0 + + # Cross-run download: the artifact lives on the triggering build run, not this one. + - name: Download built UI artifact + if: steps.cfg.outputs.ready == 'true' + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + name: ui-preview-dist + path: preview-dist + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + + # The Wrangler config is written HERE (trusted) — never taken from the PR — so a fork cannot + # control bindings, routes, or vars. It points at the downloaded built bundle. + - name: Write trusted preview Wrangler config + if: steps.cfg.outputs.ready == 'true' + run: | + cat > preview-dist/server/wrangler.preview.json <<'JSON' + { + "compatibility_date": "2026-05-28", + "name": "gittensory-ui", + "workers_dev": true, + "preview_urls": true, + "compatibility_flags": ["nodejs_compat"], + "placement": { + "mode": "smart" + }, + "observability": { + "enabled": true, + "logs": { + "enabled": true, + "head_sampling_rate": 1 + }, + "traces": { + "enabled": true, + "head_sampling_rate": 1 + } + }, + "vars": { + "VITE_GITTENSORY_API_ORIGIN": "https://gittensory-api.aethereal.dev" + }, + "routes": [ + { + "pattern": "gittensory.aethereal.dev", + "custom_domain": true + } + ], + "main": "index.mjs", + "assets": { + "binding": "ASSETS", + "directory": "../client" + }, + "no_bundle": true, + "rules": [ + { + "type": "ESModule", + "globs": ["**/*.mjs", "**/*.js"] + } + ] + } + JSON + + - name: Upload preview version + id: upload + if: steps.cfg.outputs.ready == 'true' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -o pipefail + out=$(wrangler versions upload --config preview-dist/server/wrangler.preview.json 2>&1 | tee /dev/stderr) + url=$(printf '%s\n' "$out" | grep -oE 'https://[a-z0-9.-]+\.workers\.dev' | head -n1) + if [ -z "$url" ]; then + echo "::error::Could not parse a preview URL from wrangler output" + exit 1 + fi + echo "preview_url=$url" >> "$GITHUB_OUTPUT" + echo "Preview: $url" + + - name: Record deployment for Reviewbot + if: steps.cfg.outputs.ready == 'true' && steps.upload.outputs.preview_url != '' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const url = ${{ toJSON(steps.upload.outputs.preview_url) }}; + // Trust only the GitHub-set head_sha — never fork-supplied data. + const sha = context.payload.workflow_run.head_sha; + // Resolve the PR from the head commit. workflow_run.pull_requests is EMPTY for fork PRs, + // so look it up from the commit (the base repo holds the fork head as refs/pull/N/head). + const assoc = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: sha, + }); + const slug = `${context.repo.owner}/${context.repo.repo}`; + const open = assoc.data.find((p) => p.state === "open" && p.base.repo.full_name === slug); + const prNumber = open?.number ?? context.payload.workflow_run.pull_requests?.[0]?.number; + if (!prNumber) { + core.setFailed(`Could not resolve an open PR for ${sha} — skipping deployment record.`); + return; + } + const deployment = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: sha, + environment: `preview/pr-${prNumber}`, + auto_merge: false, + required_contexts: [], + transient_environment: true, + description: "Gittensory UI preview", + // Reviewbot reads `pr` here to re-review this exact PR once the preview is live. + payload: JSON.stringify({ pr: prNumber, head_sha: sha }), + }); + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: deployment.data.id, + state: "success", + environment: `preview/pr-${prNumber}`, + environment_url: url, + description: "Preview ready", + }); + core.notice(`Preview deployment recorded for PR #${prNumber}: ${url}`); diff --git a/.github/workflows/ui-preview.yml b/.github/workflows/ui-preview.yml index 3b1cc42517..5ce8082fe5 100644 --- a/.github/workflows/ui-preview.yml +++ b/.github/workflows/ui-preview.yml @@ -1,15 +1,14 @@ -name: UI Preview Deploy +name: UI Preview Build -# Builds a per-PR preview of the gittensory UI Worker and records it as a GitHub -# Deployment with an `environment_url`. Reviewbot reads that URL to render the -# "after" screenshot in its before/after review comment (taopedia-style). +# Build half of the fork-safe per-PR preview pipeline. Runs for EVERY PR — including forks — and +# deliberately WITHOUT secrets (fork PRs get a read-only token): it only produces the built `dist` +# artifact. The trusted `ui-preview-deploy.yml` (triggered on workflow_run) then deploys that artifact +# WITH secrets and records the GitHub Deployment that Reviewbot reads for the "after" screenshot. # -# Required repo secrets (until both are set, this workflow no-ops with a notice): -# CLOUDFLARE_API_TOKEN — token with "Workers Scripts:Edit" on the account -# CLOUDFLARE_ACCOUNT_ID — the Cloudflare account id that owns gittensory-ui -# -# Fork PRs do not receive secrets, so previews only run for same-repo branches; -# Reviewbot degrades gracefully to a before-only comment for forks. +# Why split: a preview requires building the PR's UI, and building runs the PR's (possibly fork-authored) +# code. Doing that here with no secret access — and deploying the resulting bundle in a separate trusted +# step that never executes fork code — is the standard way to give fork PRs previews without exposing +# Cloudflare credentials to untrusted code. on: pull_request: @@ -20,179 +19,43 @@ on: permissions: contents: read - deployments: write concurrency: - group: ui-preview-${{ github.event.pull_request.number }} + group: ui-preview-build-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: build: name: Build UI preview artifact - # Secrets are unavailable to fork PRs — skip there (Reviewbot shows before-only). - if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest timeout-minutes: 20 - outputs: - ready: ${{ steps.cfg.outputs.ready }} steps: - - name: Check Cloudflare secrets - id: cfg - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - run: | - if [ -n "$CLOUDFLARE_API_TOKEN" ] && [ -n "$CLOUDFLARE_ACCOUNT_ID" ]; then - echo "ready=true" >> "$GITHUB_OUTPUT" - else - echo "ready=false" >> "$GITHUB_OUTPUT" - echo "::notice::CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID not set — skipping preview deploy (Reviewbot will post a before-only comment)." - fi - - name: Checkout PR head - if: steps.cfg.outputs.ready == 'true' uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Setup Node - if: steps.cfg.outputs.ready == 'true' uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version-file: .nvmrc cache: npm - name: Install dependencies - if: steps.cfg.outputs.ready == 'true' run: npm ci - name: Build UI - if: steps.cfg.outputs.ready == 'true' env: VITE_GITTENSORY_API_ORIGIN: https://gittensory-api.aethereal.dev run: npm run ui:build + # The trusted deploy workflow downloads this by name + run-id. It contains only the built bundle + # (server/ + client/) — no secrets, no source needed downstream. - name: Upload built UI artifact - if: steps.cfg.outputs.ready == 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ui-preview-dist path: apps/gittensory-ui/dist if-no-files-found: error retention-days: 1 - - upload: - name: Upload UI preview version - needs: build - if: needs.build.outputs.ready == 'true' - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Setup Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 - with: - node-version: 22 - - - name: Install trusted Wrangler - run: npm install --global wrangler@4.95.0 - - - name: Download built UI artifact - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 - with: - name: ui-preview-dist - path: preview-dist - - - name: Write trusted preview Wrangler config - run: | - cat > preview-dist/server/wrangler.preview.json <<'JSON' - { - "compatibility_date": "2026-05-28", - "name": "gittensory-ui", - "workers_dev": true, - "preview_urls": true, - "compatibility_flags": ["nodejs_compat"], - "placement": { - "mode": "smart" - }, - "observability": { - "enabled": true, - "logs": { - "enabled": true, - "head_sampling_rate": 1 - }, - "traces": { - "enabled": true, - "head_sampling_rate": 1 - } - }, - "vars": { - "VITE_GITTENSORY_API_ORIGIN": "https://gittensory-api.aethereal.dev" - }, - "routes": [ - { - "pattern": "gittensory.aethereal.dev", - "custom_domain": true - } - ], - "main": "index.mjs", - "assets": { - "binding": "ASSETS", - "directory": "../client" - }, - "no_bundle": true, - "rules": [ - { - "type": "ESModule", - "globs": ["**/*.mjs", "**/*.js"] - } - ] - } - JSON - - - name: Upload preview version - id: upload - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - run: | - set -o pipefail - out=$(wrangler versions upload --config preview-dist/server/wrangler.preview.json 2>&1 | tee /dev/stderr) - url=$(printf '%s\n' "$out" | grep -oE 'https://[a-z0-9.-]+\.workers\.dev' | head -n1) - if [ -z "$url" ]; then - echo "::error::Could not parse a preview URL from wrangler output" - exit 1 - fi - echo "preview_url=$url" >> "$GITHUB_OUTPUT" - echo "Preview: $url" - - - name: Record deployment for Reviewbot - if: steps.upload.outputs.preview_url != '' - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - script: | - const url = ${{ toJSON(steps.upload.outputs.preview_url) }}; - const pr = context.payload.pull_request; - const sha = pr.head.sha; - const deployment = await github.rest.repos.createDeployment({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: sha, - environment: `preview/pr-${pr.number}`, - auto_merge: false, - required_contexts: [], - transient_environment: true, - description: "Gittensory UI preview", - // Reviewbot reads `pr` here to re-review this exact PR once the preview is live. - payload: JSON.stringify({ pr: pr.number, head_sha: sha }), - }); - await github.rest.repos.createDeploymentStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - deployment_id: deployment.data.id, - state: "success", - environment: `preview/pr-${pr.number}`, - environment_url: url, - description: "Preview ready", - }); - core.notice(`Preview deployment recorded: ${url}`); From 82b12cc9081a6931adfc14bac58b7ebded94d76a Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:31:18 -0700 Subject: [PATCH 2/2] ci(ui-preview): harden the trusted deploy job (address PR review) Address the security findings + reviewer notes on the fork-preview deploy workflow: - Bind the deploy job (the only one with Cloudflare secrets) to a `preview` GitHub environment so approval gating / environment-scoped secrets can be attached. Unprotected by default so previews stay automatic. (Superagent P1) - Validate the untrusted fork-built artifact before handing it to wrangler: reject symlinks (path-traversal/exfil vector), require the expected SSR structure (server/index.mjs + client/), and allowlist file extensions so a malicious build can't smuggle scripts/binaries. (Superagent P1) - Pin the parsed preview URL to the gittensory-ui worker on workers.dev, so a changed wrangler output format or any other URL in the logs can't be recorded as the preview by mistake. (both reviewers) actionlint (incl shellcheck) clean. --- .github/workflows/ui-preview-deploy.yml | 45 +++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ui-preview-deploy.yml b/.github/workflows/ui-preview-deploy.yml index 7cb563b038..240f8bca74 100644 --- a/.github/workflows/ui-preview-deploy.yml +++ b/.github/workflows/ui-preview-deploy.yml @@ -32,6 +32,13 @@ concurrency: jobs: deploy: name: Deploy UI preview version + # Bind the ONE job that holds Cloudflare credentials to a GitHub deployment environment, so the org + # can attach approval gating and/or environment-scoped secrets to it. Unprotected by default (so + # previews stay automatic); add required reviewers in Settings → Environments → preview to require a + # manual approval before any (fork) preview deploys. + environment: + name: preview + url: ${{ steps.upload.outputs.preview_url }} # Only successful build runs that originated from a pull_request (incl forks). A path-skipped or # failed build still fires workflow_run with a non-success conclusion — ignore those. if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' }} @@ -71,6 +78,37 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} + # The artifact was produced by the UNTRUSTED build (fork code). Validate it before handing it to + # wrangler: reject symlinks (a path-traversal / exfil vector when the bundle is processed), require + # the expected SSR build structure, and allowlist file extensions so a malicious build can't smuggle + # scripts/binaries/unexpected paths into the deploy. + - name: Validate downloaded artifact + if: steps.cfg.outputs.ready == 'true' + run: | + set -euo pipefail + cd preview-dist + # 1) No symlinks anywhere in the bundle. + symlinks="$(find . -type l)" + if [ -n "$symlinks" ]; then + echo "::error::Artifact contains symlinks — refusing to deploy:" + printf '%s\n' "$symlinks" + exit 1 + fi + # 2) Required SSR build structure (server worker entry + client assets dir). + test -f server/index.mjs || { echo "::error::artifact missing server/index.mjs"; exit 1; } + test -d client || { echo "::error::artifact missing client/ assets dir"; exit 1; } + # 3) Allowlist file extensions — fail on anything that isn't a normal web/build output (blocks + # smuggled scripts/binaries). A few extensionless CF asset files are explicitly permitted. + unexpected="$(find . -regextype posix-extended -type f \ + -not -iregex '.*\.(mjs|js|cjs|map|json|css|html?|txt|svg|png|jpe?g|gif|webp|avif|ico|bmp|woff2?|ttf|otf|eot|wasm|xml|webmanifest|md|csv|wgsl|glb|gltf)$' \ + -not -name '_headers' -not -name '_redirects' -not -name '_routes.json' -not -name '.assetsignore')" + if [ -n "$unexpected" ]; then + echo "::error::Artifact contains unexpected file types — refusing to deploy:" + printf '%s\n' "$unexpected" + exit 1 + fi + echo "Artifact validated: no symlinks, expected SSR structure, allowlisted file types only." + # The Wrangler config is written HERE (trusted) — never taken from the PR — so a fork cannot # control bindings, routes, or vars. It points at the downloaded built bundle. - name: Write trusted preview Wrangler config @@ -130,9 +168,12 @@ jobs: run: | set -o pipefail out=$(wrangler versions upload --config preview-dist/server/wrangler.preview.json 2>&1 | tee /dev/stderr) - url=$(printf '%s\n' "$out" | grep -oE 'https://[a-z0-9.-]+\.workers\.dev' | head -n1) + # Take a workers.dev URL that belongs to the gittensory-ui worker, so any other URL in the logs + # (or a changed output format) can't be recorded as the preview by mistake. Tolerant of the + # version-alias prefix (`-gittensory-ui..workers.dev`). + url=$(printf '%s\n' "$out" | grep -oiE 'https://[a-z0-9.-]+\.workers\.dev' | grep -i 'gittensory-ui' | head -n1) if [ -z "$url" ]; then - echo "::error::Could not parse a preview URL from wrangler output" + echo "::error::Could not parse an expected gittensory-ui preview URL from wrangler output" exit 1 fi echo "preview_url=$url" >> "$GITHUB_OUTPUT"