|
| 1 | +name: Deploy UI preview |
| 2 | +description: >- |
| 3 | + The trusted deploy half of the per-PR preview pipeline, shared by its two call sites so they can |
| 4 | + never drift (the setup-workspace precedent): ui-preview.yml deploys same-repo PRs inline in the |
| 5 | + build job, and ui-preview-deploy.yml deploys fork PRs from the workflow_run trust boundary. It |
| 6 | + validates a built dist bundle, writes the preview Wrangler config HERE (never taken from the PR -- |
| 7 | + a fork cannot control bindings, routes, or vars), uploads a 0%-traffic preview version (a |
| 8 | + workers.dev URL; wrangler only uploads the bundle, it never executes it), and records the GitHub |
| 9 | + Deployment + success status that Reviewbot reads for the "after" screenshot. Callers must have |
| 10 | + Node available (actions/setup-node) before invoking, and keep their own failure()-gated step that |
| 11 | + records a `failure` deployment_status (that step is caller-specific: it must also catch failures |
| 12 | + of the caller's OWN earlier steps, which this action cannot see). |
| 13 | +inputs: |
| 14 | + pr-number: |
| 15 | + description: The pull request number this preview belongs to (Reviewbot re-reviews it on success). |
| 16 | + required: true |
| 17 | + head-sha: |
| 18 | + description: The PR head SHA the deployment record points at. Pass only GitHub-set values, never fork-supplied data. |
| 19 | + required: true |
| 20 | + dist-dir: |
| 21 | + description: Path to the built UI bundle (server/ + client/), relative to the workspace. |
| 22 | + required: true |
| 23 | + cloudflare-api-token: |
| 24 | + description: Cloudflare API token with "Workers Scripts:Edit" on the account. |
| 25 | + required: true |
| 26 | + cloudflare-account-id: |
| 27 | + description: The Cloudflare account id that owns loopover-ui. |
| 28 | + required: true |
| 29 | + github-token: |
| 30 | + description: Token with deployments:write, used to record the Deployment for Reviewbot. |
| 31 | + required: true |
| 32 | +outputs: |
| 33 | + preview_url: |
| 34 | + description: The workers.dev preview URL the upload produced. |
| 35 | + value: ${{ steps.upload.outputs.preview_url }} |
| 36 | +runs: |
| 37 | + using: composite |
| 38 | + steps: |
| 39 | + - name: Install trusted Wrangler |
| 40 | + shell: bash |
| 41 | + run: npm install --global wrangler@4.95.0 |
| 42 | + |
| 43 | + # The bundle may have been produced by an UNTRUSTED build (fork code; harmless for the trusted |
| 44 | + # same-repo call site, where the same checks still catch build anomalies). Validate it before |
| 45 | + # handing it to wrangler: reject symlinks (a path-traversal / exfil vector when the bundle is |
| 46 | + # processed), require the expected SSR build structure, and allowlist file extensions so a |
| 47 | + # malicious build can't smuggle scripts/binaries/unexpected paths into the deploy. |
| 48 | + - name: Validate dist bundle |
| 49 | + shell: bash |
| 50 | + run: | |
| 51 | + set -euo pipefail |
| 52 | + cd "${{ inputs.dist-dir }}" |
| 53 | + # 1) No symlinks anywhere in the bundle. |
| 54 | + symlinks="$(find . -type l)" |
| 55 | + if [ -n "$symlinks" ]; then |
| 56 | + echo "::error::Bundle contains symlinks — refusing to deploy:" |
| 57 | + printf '%s\n' "$symlinks" |
| 58 | + exit 1 |
| 59 | + fi |
| 60 | + # 2) Required SSR build structure (server worker entry + client assets dir). |
| 61 | + test -f server/index.mjs || { echo "::error::bundle missing server/index.mjs"; exit 1; } |
| 62 | + test -d client || { echo "::error::bundle missing client/ assets dir"; exit 1; } |
| 63 | + # 3) Allowlist file extensions — fail on anything that isn't a normal web/build output (blocks |
| 64 | + # smuggled scripts/binaries). A few extensionless CF asset files are explicitly permitted. |
| 65 | + # `zip` covers the served downloads (e.g. /downloads/loopover-extension.zip) — a passive |
| 66 | + # static asset wrangler only uploads (never executes), so allowing it doesn't run fork code. |
| 67 | + unexpected="$(find . -regextype posix-extended -type f \ |
| 68 | + -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|zip|wgsl|glb|gltf)$' \ |
| 69 | + -not -name '_headers' -not -name '_redirects' -not -name '_routes.json' -not -name '.assetsignore')" |
| 70 | + if [ -n "$unexpected" ]; then |
| 71 | + echo "::error::Bundle contains unexpected file types — refusing to deploy:" |
| 72 | + printf '%s\n' "$unexpected" |
| 73 | + exit 1 |
| 74 | + fi |
| 75 | + echo "Bundle validated: no symlinks, expected SSR structure, allowlisted file types only." |
| 76 | +
|
| 77 | + # The Wrangler config is written HERE (trusted) — never taken from the PR — so a fork cannot |
| 78 | + # control bindings, routes, or vars. It points at the validated bundle. It deliberately OMITS the |
| 79 | + # production custom-domain route: `wrangler versions upload` creates a 0%-traffic preview version |
| 80 | + # (a workers.dev URL) and applies no routes, so the route is unused here — and omitting it |
| 81 | + # guarantees that even a future switch to `wrangler deploy` could never point fork-built code at |
| 82 | + # the production domain. Do not add a `routes` block to this preview config. |
| 83 | + - name: Write trusted preview Wrangler config |
| 84 | + shell: bash |
| 85 | + run: | |
| 86 | + cat > "${{ inputs.dist-dir }}/server/wrangler.preview.json" <<'JSON' |
| 87 | + { |
| 88 | + "compatibility_date": "2026-05-28", |
| 89 | + "name": "loopover-ui", |
| 90 | + "workers_dev": true, |
| 91 | + "preview_urls": true, |
| 92 | + "compatibility_flags": ["nodejs_compat"], |
| 93 | + "placement": { |
| 94 | + "mode": "smart" |
| 95 | + }, |
| 96 | + "observability": { |
| 97 | + "enabled": true, |
| 98 | + "logs": { |
| 99 | + "enabled": true, |
| 100 | + "head_sampling_rate": 1 |
| 101 | + }, |
| 102 | + "traces": { |
| 103 | + "enabled": true, |
| 104 | + "head_sampling_rate": 1 |
| 105 | + } |
| 106 | + }, |
| 107 | + "vars": { |
| 108 | + "VITE_LOOPOVER_API_ORIGIN": "https://api.loopover.ai" |
| 109 | + }, |
| 110 | + "main": "index.mjs", |
| 111 | + "assets": { |
| 112 | + "binding": "ASSETS", |
| 113 | + "directory": "../client" |
| 114 | + }, |
| 115 | + "no_bundle": true, |
| 116 | + "rules": [ |
| 117 | + { |
| 118 | + "type": "ESModule", |
| 119 | + "globs": ["**/*.mjs", "**/*.js"] |
| 120 | + } |
| 121 | + ] |
| 122 | + } |
| 123 | + JSON |
| 124 | +
|
| 125 | + - name: Upload preview version |
| 126 | + id: upload |
| 127 | + shell: bash |
| 128 | + env: |
| 129 | + CLOUDFLARE_API_TOKEN: ${{ inputs.cloudflare-api-token }} |
| 130 | + CLOUDFLARE_ACCOUNT_ID: ${{ inputs.cloudflare-account-id }} |
| 131 | + run: | |
| 132 | + set -o pipefail |
| 133 | + out=$(wrangler versions upload --config "${{ inputs.dist-dir }}/server/wrangler.preview.json" 2>&1 | tee /dev/stderr) |
| 134 | + # Take a workers.dev URL that belongs to the loopover-ui worker, so any other URL in the logs |
| 135 | + # (or a changed output format) can't be recorded as the preview by mistake. Tolerant of the |
| 136 | + # version-alias prefix (`<alias>-loopover-ui.<sub>.workers.dev`). |
| 137 | + url=$(printf '%s\n' "$out" | grep -oiE 'https://[a-z0-9.-]+\.workers\.dev' | grep -i 'loopover-ui' | head -n1) |
| 138 | + if [ -z "$url" ]; then |
| 139 | + echo "::error::Could not parse an expected loopover-ui preview URL from wrangler output" |
| 140 | + exit 1 |
| 141 | + fi |
| 142 | + echo "preview_url=$url" >> "$GITHUB_OUTPUT" |
| 143 | + echo "Preview: $url" |
| 144 | +
|
| 145 | + - name: Record deployment for Reviewbot |
| 146 | + if: ${{ steps.upload.outputs.preview_url != '' }} |
| 147 | + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 |
| 148 | + with: |
| 149 | + github-token: ${{ inputs.github-token }} |
| 150 | + script: | |
| 151 | + const url = ${{ toJSON(steps.upload.outputs.preview_url) }}; |
| 152 | + const sha = ${{ toJSON(inputs.head-sha) }}; |
| 153 | + const prNumber = Number(${{ toJSON(inputs.pr-number) }}); |
| 154 | + const deployment = await github.rest.repos.createDeployment({ |
| 155 | + owner: context.repo.owner, |
| 156 | + repo: context.repo.repo, |
| 157 | + ref: sha, |
| 158 | + environment: `preview/pr-${prNumber}`, |
| 159 | + auto_merge: false, |
| 160 | + required_contexts: [], |
| 161 | + transient_environment: true, |
| 162 | + description: "LoopOver UI preview", |
| 163 | + // Reviewbot reads `pr` here to re-review this exact PR once the preview is live. |
| 164 | + payload: JSON.stringify({ pr: prNumber, head_sha: sha }), |
| 165 | + }); |
| 166 | + await github.rest.repos.createDeploymentStatus({ |
| 167 | + owner: context.repo.owner, |
| 168 | + repo: context.repo.repo, |
| 169 | + deployment_id: deployment.data.id, |
| 170 | + state: "success", |
| 171 | + environment: `preview/pr-${prNumber}`, |
| 172 | + environment_url: url, |
| 173 | + description: "Preview ready", |
| 174 | + }); |
| 175 | + core.notice(`Preview deployment recorded for PR #${prNumber}: ${url}`); |
0 commit comments