-
-
Notifications
You must be signed in to change notification settings - Fork 91
ci(ui-preview): give fork PRs preview deploys (build-untrusted / deploy-trusted split) #643
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,225 @@ | ||
| 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 | ||
| # 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' }} | ||
| 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 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 | ||
| 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 | ||
|
JSONbored marked this conversation as resolved.
|
||
| 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) | ||
| # 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 (`<alias>-gittensory-ui.<sub>.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 an expected gittensory-ui 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}`); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.