Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 225 additions & 0 deletions .github/workflows/ui-preview-deploy.yml
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:
Comment thread
JSONbored marked this conversation as resolved.
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
Comment thread
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}`);
Loading