UI Preview Deploy #3253
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
| 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}.`); |