Skip to content

UI Preview Deploy #3254

UI Preview Deploy

UI Preview Deploy #3254

name: UI Preview Deploy
# FORK-ONLY deploy half of the per-PR preview pipeline (since 2026-07-24 — same-repo PRs deploy
# inline in ui-preview.yml's build-deploy job, and this job skips runner-free for their builds).
# 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 (the only checkout below is of the default
# branch, solely to invoke the shared local composite action — it never checks out or runs PR/fork
# source), deploys a transient preview version via .github/actions/deploy-ui-preview, 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 loopover-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
pull-requests: read # resolve the fork PR for this build by matching head SHA
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 fork-facing 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.deploy.outputs.preview_url }}
# Only successful build runs that originated from a FORK pull_request: same-repo PRs deploy inline
# in ui-preview.yml (their build workflow still completes and fires this trigger — the repo
# comparison below is what lets those runs skip without consuming a runner). A path-skipped or
# failed build still fires workflow_run with a non-success conclusion — ignore those too.
if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name }}
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
# TRUSTED checkout of the default branch (github.sha for a workflow_run event is the default
# branch head) — required only so the local `uses: ./.github/actions/deploy-ui-preview` reference
# below can find its action.yml. Never the fork's code.
- name: Checkout default branch
if: steps.cfg.outputs.ready == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup Node
if: steps.cfg.outputs.ready == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
# The trusted checkout above restored .nvmrc, so the old hand-synced hardcoded version
# (the no-checkout exception this job used to carry) is no longer needed.
node-version-file: .nvmrc
# Resolve the fork PR this build belongs to BEFORE deploying, so the composite can record the
# deployment against it. Both easy signals are EMPTY for fork PRs (the common case here):
# workflow_run.pull_requests is empty for cross-repo runs, and the commit→PR association API
# doesn't index a fork-head commit from the base repo. So we ALSO match the open PR whose head
# points at this exact build SHA — head.sha is GitHub-set (the base repo tracks the fork head as
# refs/pull/N/head), so it's safe to trust even for forks.
- name: Resolve PR for this build
id: pr
if: steps.cfg.outputs.ready == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const sha = context.payload.workflow_run.head_sha; // GitHub-set; never fork-supplied
const slug = `${context.repo.owner}/${context.repo.repo}`;
let prNumber = context.payload.workflow_run.pull_requests?.[0]?.number;
if (!prNumber) {
const assoc = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: sha,
});
prNumber = assoc.data.find((p) => p.state === "open" && p.base.repo.full_name === slug)?.number;
}
if (!prNumber) {
// Fork-safe fallback: scan open PRs for the one whose head is this build's commit.
const openPrs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
per_page: 100,
});
prNumber = openPrs.find((p) => p.head.sha === sha)?.number;
}
if (!prNumber) {
core.setFailed(`Could not resolve an open PR for ${sha} — skipping deploy.`);
return;
}
core.setOutput("number", String(prNumber));
# 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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ui-preview-dist
path: preview-dist
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
# Validation, trusted config, wrangler upload, and the success Deployment record all live in the
# shared composite (kept byte-identical with ui-preview.yml's same-repo call site by construction).
- name: Deploy preview
id: deploy
if: steps.cfg.outputs.ready == 'true'
uses: ./.github/actions/deploy-ui-preview
with:
pr-number: ${{ steps.pr.outputs.number }}
head-sha: ${{ github.event.workflow_run.head_sha }}
dist-dir: preview-dist
cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
cloudflare-account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Record FAILED deployment for Reviewbot
# Runs when an earlier step in this job failed (artifact validation rejected the bundle, the
# wrangler upload errored, etc.) — i.e. a deploy was attempted but never produced a preview.
# Record a `failure` deployment_status so Reviewbot flips the "after" cell from an eternal
# spinner to a terminal "preview deploy failed" card, instead of waiting forever for a success
# event that will never come. Gated on CF creds so a credential-less skip records nothing;
# reuses the resolution step's PR (if THAT failed, there is nothing to record against — the
# resolution step already setFailed with the reason).
if: ${{ failure() && steps.cfg.outputs.ready == 'true' && steps.pr.outputs.number != '' }}
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const sha = context.payload.workflow_run.head_sha; // GitHub-set; never fork-supplied
const prNumber = Number(${{ toJSON(steps.pr.outputs.number) }});
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: "LoopOver UI preview (failed)",
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: "failure",
environment: `preview/pr-${prNumber}`,
description: "Preview deploy failed",
});
core.notice(`Recorded FAILED preview deployment for PR #${prNumber}.`);