When reviewing a PR, I want to see the changes live. Not a screenshot, not a description. The actual rendered page.
I built a preview deployment system that deploys every PR to an isolated environment and puts a link right in the PR.
The problem
Code review for a static site is awkward. You read the diff, imagine what it looks like, approve, merge, wait for deploy, then check production.
If something looks wrong, you’ve already shipped it.
What I wanted:
- Every PR gets its own preview URL
- Preview reflects the exact state of the PR
- Reviewers see the link without hunting for it
- Status shows whether the preview is ready
GitHub’s deployment primitives
GitHub provides three relevant APIs:
Commit Status: The colored check marks next to commits. Can be pending, success, failure, or error. Shows in PR checks section.
Deployments: Records of where code was deployed. Creates entries in the “Environments” section of the repo.
Deployment Status: Updates for a deployment (in_progress, success, failure). Can include an environment URL.
I use all three to create a complete experience:
flowchart TD
A[PR opened] --> B[Commit status: pending]
A --> C[Deployment created]
B --> D[Build & deploy]
C --> D
D --> E[Commit status: success + URL]
D --> F[Deployment status: success]
D --> G[PR comment: Preview link table]
style A fill:#e3f2fd
style E fill:#c8e6c9
style F fill:#c8e6c9
style G fill:#c8e6c9
The workflow
Here’s the core of preview.yml:
name: Preview Deployment
on:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
preview:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
deployments: write
statuses: write
The concurrency block is important. If I push multiple commits quickly, only the last one matters. Cancel in-progress builds to save resources.
Setting commit status
First step: tell reviewers a preview is building.
- name: Set commit status - pending
uses: actions/github-script@v7
with:
script: |
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: '${{ github.event.pull_request.head.sha }}',
state: 'pending',
target_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}',
description: 'Building preview...',
context: 'Preview Deployment'
});
The context field is the name shown in the checks list. The target_url links to the Actions run while building, then the preview URL once deployed.
Creating a deployment
Deployments are GitHub’s way of tracking where code goes:
- name: Create deployment
id: deployment
uses: actions/github-script@v7
with:
script: |
const deployment = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref: '${{ github.event.pull_request.head.sha }}',
environment: 'Preview',
auto_merge: false,
required_contexts: [],
description: 'Preview for PR #${{ github.event.pull_request.number }}'
});
const id = deployment.data.id;
core.setOutput('deployment_id', id);
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: id,
state: 'in_progress',
description: 'Building and deploying preview...'
});
The required_contexts: [] skips status checks. Preview deploys shouldn’t wait for tests.
Building with CDN fallback
Here’s the key optimization: previews don’t regenerate existing assets.
- name: Build
env:
PUBLIC_CDN_URL: https://cdn.fontalternatives.com
run: |
npm run precompute
npm run build:html
The PUBLIC_CDN_URL tells the build to reference production CDN for images. New fonts in the PR get their assets uploaded to a preview namespace, but existing fonts use production assets.
This turns a 1-hour full build into a 2-minute HTML-only build. Each preview also runs through the three-level validation system before going live.
Deploying to Cloudflare
I use a separate Workers environment for previews:
- name: Deploy preview
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
command: deploy --env preview
The wrangler.jsonc defines the preview environment:
{
"name": "fontalternatives",
"env": {
"preview": {
"name": "fontalternatives-preview",
"routes": [
{
"pattern": "fontalternatives-preview.ruzicic.workers.dev",
"custom_domain": false
}
]
}
}
}
All PRs deploy to the same preview worker. The HTML is replaced on each deploy. Since assets live on R2/CDN, there’s no conflict.
The PR comment
After deployment, post a comment with the preview link:
- name: Comment preview URL
uses: actions/github-script@v7
with:
script: |
const prNumber = ${{ github.event.pull_request.number }};
const previewUrl = 'https://fontalternatives-preview.ruzicic.workers.dev';
const sha = '${{ github.event.pull_request.head.sha }}'.substring(0, 7);
const body = `## Preview Deployment
| Status | URL |
|--------|-----|
| Ready | [${previewUrl}](${previewUrl}) |
**Commit:** \`${sha}\``;
// Find existing comment to update
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const botComment = comments.find(c =>
c.user?.login === 'github-actions[bot]' &&
c.body?.includes('Preview Deployment')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: body,
});
}
Updating the existing comment (instead of creating new ones) keeps the PR clean when pushing multiple commits.
Font-specific preview URLs
For font request PRs, I include a direct link to the new font page:
- name: Update PR description
uses: actions/github-script@v7
with:
script: |
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
let body = pr.body || '';
// Extract font slug from branch name
const branchMatch = pr.head.ref.match(/^font-request\/(.+)$/);
const fontSlug = branchMatch ? branchMatch[1] : null;
if (fontSlug && body.includes('<!-- PREVIEW_URL_PLACEHOLDER -->')) {
const fontPageUrl = `${previewUrl}/alternatives/${fontSlug}/`;
const newSection = `| Status | Preview | Font Page |
|--------|---------|-----------|
| Ready | [Site](${previewUrl}) | [/alternatives/${fontSlug}/](${fontPageUrl}) |`;
body = body.replace('<!-- PREVIEW_URL_PLACEHOLDER -->', newSection);
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
body: body,
});
}
The placeholder in the PR template gets replaced with the actual preview table.
Finalizing status
After successful deploy, update both commit status and deployment:
- name: Set commit status - success
uses: actions/github-script@v7
with:
script: |
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: '${{ github.event.pull_request.head.sha }}',
state: 'success',
target_url: '${{ env.PREVIEW_URL }}',
description: 'Preview ready',
context: 'Preview Deployment'
});
- name: Update deployment status - success
uses: actions/github-script@v7
with:
script: |
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: ${{ steps.deployment.outputs.deployment_id }},
state: 'success',
environment_url: '${{ env.PREVIEW_URL }}',
description: 'Preview deployed successfully'
});
The environment_url in deployment status creates the “View deployment” button in GitHub.
Handling failures
If the build fails, update status accordingly:
- name: Set commit status - failure
if: failure()
uses: actions/github-script@v7
with:
script: |
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: '${{ github.event.pull_request.head.sha }}',
state: 'failure',
target_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}',
description: 'Preview deployment failed',
context: 'Preview Deployment'
});
The if: failure() condition runs this step only when previous steps failed.
The reviewer experience
When someone opens a PR:
- They see “Preview Deployment: pending” in the checks
- A few minutes later, it turns green with “Preview ready”
- Clicking the status goes directly to the preview site
- The PR comment has a formatted table with links
- For font PRs, there’s a direct link to the new font page
No hunting through Actions logs. No copying URLs from build output. The link is right there.
Smart production builds
The preview system pays dividends at merge time. When a PR is merged, the production workflow detects that preview assets already exist and skips regeneration:
- name: Check for preview assets
run: |
PROMOTED=$(npx tsx scripts/promote-all-previews.ts --count-only)
echo "promoted_count=$PROMOTED" >> $GITHUB_OUTPUT
- name: Determine build type
run: |
if [ "$PROMOTED" != "0" ]; then
echo "type=html" >> $GITHUB_OUTPUT # Fast path
elif [ "$CONTENT_CHANGED" = "true" ]; then
echo "type=full" >> $GITHUB_OUTPUT # New fonts need assets
else
echo "type=html" >> $GITHUB_OUTPUT # No changes
fi
The decision flow:
| Scenario | Build Type | Time |
|---|---|---|
| PR merged (has preview assets) | HTML-only | ~2-3 min |
| Direct push (no new fonts) | HTML-only | ~2-3 min |
| Direct push (new fonts) | Full | ~12-15 min |
| Manual full build trigger | Full | ~12-15 min |
Preview assets are promoted before the build:
- name: Promote preview assets
if: steps.check-preview.outputs.promoted_count != '0'
run: npx tsx scripts/promote-all-previews.ts
# Copies pr/{N}/* to production root, updates manifest, deletes pr/{N}/
This means most production deploys complete in under 3 minutes instead of 30+.
Tradeoffs
What I gained:
- Visual review before merge
- Confidence in changes
- Faster review cycles (no back-and-forth about appearance)
- Fast production deploys (~2-3 min when preview exists)
What I lost:
- Build time per PR (~2-3 minutes)
- Cloudflare Workers usage (minimal, free tier)
- Complexity in the workflow
Decisions I’d reconsider:
- Single preview environment means only one PR can be previewed at a time. For a solo project, this is fine. For a team, you’d want per-PR environments.
- Asset isolation could be better. Currently, new assets go to
pr/{number}/in R2, but cleanup happens on PR close. Old preview assets can accumulate.
The complete flow
flowchart TD
A[PR opened] --> B[Workflow triggered]
B --> C[Set pending status]
C --> D[Create deployment]
subgraph build[Build Phase]
D --> E[Checkout & build]
E --> F[npm run build:html]
E --> G[Upload new assets to R2]
end
F --> H[Deploy to Cloudflare Workers]
G --> H
subgraph status[Status Updates]
H --> I[Update commit status: success]
I --> J[Update deployment status: success]
J --> K[Post/update PR comment]
end
K --> L[Reviewer clicks preview link]
style A fill:#e3f2fd
style L fill:#c8e6c9
Total time from push to preview: under 3 minutes.