When I open a pull request on the nonogram app, a bot comments back within a couple of minutes:

🎮 PR Preview Deployed! Try it out: https://jasonblackhurst.github.io/nonogram/pr-preview/pr-42/

That link is the actual change, built and live, on a URL that exists only for as long as the PR is open. I can play the branch on my phone before I merge it. No Vercel, no Netlify, no paid preview tier — just GitHub Pages and about sixty lines of YAML. Here’s how it works and why it earns its keep.

Why a static-build game wants this

A nonogram is a feel thing. Tap latency, how a hard board animates in, whether the hint columns clip at a given width — none of that shows up in a diff and only some of it shows up in a golden test. The only honest way to review a UI change is to use it. And “check out the branch, flutter build web, serve it locally, open a browser” is enough friction that, reviewing my own work on a solo project, I’d skip it. Then I’d merge a layout regression I’d have caught in four seconds of actually playing.

A per-PR preview removes the friction entirely. The review is a link.

The trick: subdirectories on one Pages branch

GitHub Pages serves a single branch (gh-pages here) as one static site. The whole mechanism is built on that one idea: give each PR its own subdirectory and never let the deploys collide.

The production deploy (deploy-pages.yml) builds on every push to main and publishes to the root:

- run: flutter build web --base-href /$/
- uses: peaceiris/actions-gh-pages@v3
  with:
    publish_dir: ./build/web
    keep_files: true

The PR preview (pr-preview.yml) does the same thing pointed at a nested path. Two pieces make it work:

- run: |
    flutter build web \
      --base-href /$/pr-preview/pr-$/
- uses: peaceiris/actions-gh-pages@v3
  with:
    publish_dir: ./build/web
    destination_dir: pr-preview/pr-$
    keep_files: true

Two details are doing all the work:

  • --base-href must match the deploy path. A Flutter web build hardcodes its asset base path at build time. If you build for /nonogram/ but serve from /nonogram/pr-preview/pr-42/, every script and font 404s and you get a white screen. The base href and the destination_dir have to agree.
  • keep_files: true is non-negotiable. Without it, actions-gh-pages wipes the branch before publishing — so deploying PR 42 would delete production and every other open preview. With it, each job only writes its own subtree and leaves the rest of the branch alone. This single flag is what lets prod, PR 41, and PR 42 coexist on one branch.

Talking back, and cleaning up

The preview job runs on opened, synchronize, and reopened, so the link is always the latest commit. Instead of spamming a new comment per push, it finds its own previous comment and edits it in place — one stable “Preview Deployed” comment that silently stays current.

The half people forget is teardown. The job also fires on closed, and that branch of the workflow checks out gh-pages and deletes the PR’s directories:

- run: |
    rm -rf pr-preview/pr-$
    rm -rf golden-review/pr-$
    git add .
    git diff --staged --quiet || git commit -m "Clean up PR #... preview"
    git push

(It also clears golden-review/ — the rendered golden-diff that pairs with the /approve-goldens bot. Same per-PR-subdirectory pattern, same cleanup.) Without this step the gh-pages branch grows a dead directory for every PR you ever open, forever. An ephemeral environment that never goes away isn’t ephemeral — it’s a slow leak with a friendly comment attached.

What this costs and what it’s worth

The cost is a build per PR push on free GitHub-hosted runners, plus a gh-pages branch that — thanks to cleanup — only ever holds production and the currently open previews. That’s the whole bill.

The worth is harder to overstate on a solo project. The thing that kills side projects isn’t the big stuff; it’s the slow erosion of every quality habit that has friction. Reviewing your own UI change has friction. A clickable link on the PR removes it, so the habit survives, so the regressions get caught. Same principle as making the solver fast enough to disappear and parking golden approval inside the PR: the best infrastructure removes a reason to cut a corner.

You don’t need a preview-environment SaaS to get this. You need one Pages branch, matching base hrefs, keep_files: true, and the discipline to clean up after yourself.


Part of a short series on the engineering behind the nonogram app — see also the screenshot-approval bot and shipping to TestFlight.