Every pull request on this blog automatically gets a live, browsable preview — deployed to a real URL, no staging server required. Here’s the complete setup and why it matters.
The Problem: Testing Changes Blind
Static sites are great. No database, no backend, no runtime surprises. But that simplicity creates a blind spot during development: you can’t see your changes running in production until you actually deploy to production.
For a personal blog, that might sound fine — just push to main and see what happens. But it’s not ideal:
| Scenario | The risk |
|---|---|
| New blog post with a pintora diagram | Might render incorrectly after minification |
| Layout change touching CSS | Could break other pages you didn’t check |
| Jekyll plugin upgrade | Subtle rendering differences you won’t catch locally |
| Baseurl-sensitive links | Works on localhost, breaks on GitHub Pages |
The standard fix for this is a staging environment — a separate deployment you can inspect before promoting changes to production. But spinning up and maintaining a whole separate server for a static blog feels wildly over-engineered.
The better answer is PR previews: a temporary, isolated deployment of exactly what’s in your pull request, automatically created every time you open or push to a PR.
How It Works on This Site
Every PR gets a live preview at:
https://mathewsachin.github.io/pr-preview/pr-{NUMBER}/
The preview is:
- Automatically created when a PR is opened or updated
- Automatically updated on every new commit to the PR branch
- Automatically deleted when the PR is closed or merged
- Hosted in the same
gh-pagesbranch as the production site — no extra infrastructure
All of this is wired up in three GitHub Actions workflow files.
The Three-Workflow Architecture
activityDiagram
partition "pr-preview.yml" {
:PR opened / pushed / closed;
if (action == closed?) then
:Skip build;
else
:Call build-site.yml;
:baseurl = /pr-preview/pr-N;
:preview = true;
endif
:Deploy or teardown preview;
}
partition "deploy.yml" {
:Push to main;
:Call build-site.yml;
:baseurl = empty;
:preview = false;
:Deploy to gh-pages root;
}
partition "build-site.yml" {
:npm ci + npm run build;
:Inject baseurl into _config.yml;
:Disable Google Analytics if preview;
:jekyll build;
:Upload site artifact;
}
The key design decision is that build-site.yml is a reusable workflow (workflow_call). Both the PR preview workflow and the production deploy workflow call it — they just pass different inputs.
build-site.yml: The Reusable Build
on:
workflow_call:
inputs:
baseurl:
description: 'Optional baseurl to inject into _config.yml'
type: string
default: ''
preview:
description: 'Whether this is a PR preview build (disables Google Analytics)'
type: boolean
default: false
It accepts two inputs:
baseurl— for previews this is/pr-preview/pr-42; for production it’s empty. This gets appended to_config.ymlat build time so everyrelative_urlfilter in Jekyll produces the right path.preview— whentrue, a second line is appended to_config.yml:google-analytics: false. You don’t want preview traffic polluting your analytics.
The baseurl injection is a one-liner:
- name: Set baseurl
if: inputs.baseurl != ''
env:
BASEURL: $
run: |
echo "baseurl: $BASEURL" >> _config.yml
Appending to _config.yml works because Jekyll takes the last value when a key appears more than once. The original file has no baseurl key, so this cleanly adds it for the build.
pr-preview.yml: The Preview Trigger
on:
pull_request:
types:
- opened
- reopened
- synchronize
- closed
concurrency: preview-$
The concurrency key ensures that rapid successive pushes to a PR don’t trigger parallel preview deployments that stomp on each other. The second run waits for the first to finish (or cancels it, depending on your config).
The actual deployment is handled by rossjrw/pr-preview-action:
- name: Deploy PR preview
uses: rossjrw/pr-preview-action@v1
with:
source-dir: ./_site
preview-branch: gh-pages
pages-base-url: MathewSachin.github.io
This action does several things automatically:
- Creates a subdirectory
/pr-preview/pr-{NUMBER}in thegh-pagesbranch and pushes the built site into it - Posts a comment on the PR with a link to the preview URL
- Updates that comment on every subsequent push
- Tears down the subdirectory and updates the comment to say “preview removed” when the PR is closed
deploy.yml: Keeping Previews Safe During Production Deploy
The production deploy uses JamesIves/github-pages-deploy-action, which by default wipes the gh-pages branch clean before deploying. That would delete every live preview.
The fix is one line:
- name: Deploy to gh-pages branch
uses: JamesIves/github-pages-deploy-action@v4
with:
folder: ./_site
branch: gh-pages
clean-exclude: pr-preview
clean-exclude: pr-preview tells the action to leave the pr-preview/ directory untouched during its cleanup pass. PR previews survive production deployments.
What Actually Gets Tested
Because the preview is a full production build — same Jekyll version, same plugins, same minification, same JEKYLL_ENV=production flag — it catches problems that a local jekyll serve won’t.
Things that have actually been caught in preview before they hit production:
- Broken relative URLs — a link that works locally (
/blog/...) but resolves incorrectly under the preview baseurl, revealing a hardcoded path that should have usedrelative_url - Minification quirks —
jekyll-minifierprocessing JavaScript differently in production mode than in development - Image processing —
jekyll_picture_taggenerating images with the right dimensions but wrong paths when baseurl changes - Pintora diagrams — the JS-rendered diagrams look fine in dev but can have sizing issues when the layout CSS is minified
The preview acts as a complete end-to-end test of the deployment pipeline itself, not just the content.
The Cost: Zero
The entire setup runs on GitHub Actions free tier. For a public repository, GitHub Actions minutes are unlimited. The gh-pages branch storage cost is negligible — each PR preview is just a folder of static HTML/CSS/JS, a few hundred KB at most, and it’s deleted when the PR closes.
No Netlify subscription. No Vercel project. No Cloudflare Pages configuration. Just the same GitHub Pages hosting the production site already uses, with a clever subdirectory trick.
Summary
| Component | Role |
|---|---|
build-site.yml | Reusable build; accepts baseurl and preview inputs |
pr-preview.yml | Triggers on PR events; builds with PR-specific baseurl; deploys via rossjrw/pr-preview-action |
deploy.yml | Production deploy; uses clean-exclude: pr-preview to preserve live previews |
rossjrw/pr-preview-action | Manages the gh-pages subdirectory and PR comments |
JamesIves/github-pages-deploy-action | Production deploy action with selective cleanup |
The whole thing is about 100 lines of YAML across three files. For a Jekyll site hosted on GitHub Pages, it’s the lowest-friction way to get staging-quality previews on every PR.