Skip to main content
  1. Posts/

A typo fix shouldn't boot a browser in CI. Mine did for months.

··1184 words·6 mins·
Nick Liu
Author
Nick Liu
Building infrastructure for Facebook Feed Ranking at Meta. Previously at Walmart, Twitter, AWS, and eBay. MS in Computer Science at Georgia Tech.
Table of Contents
I changed one sentence in a blog post, opened a pull request, and watched CI spend about 85 seconds installing a headless Chromium to confirm my prose still turned into HTML. The obvious fix, telling the workflow to ignore content paths, would have quietly broken every merge instead.

Symptom: a browser for a one-line edit
#

Every pull request on this blog runs two jobs: a lint job, and a build-and-test job. The second one builds the site with Hugo, link-checks the output with htmltest, then installs a headless Chromium and runs a Playwright suite against a live hugo server. End to end, roughly 85 seconds, and almost all of it is Playwright.

That’s a fair price when I change a layout or a shortcode. Most of my pull requests aren’t that. They’re prose: a new post, a fixed typo, a reworded paragraph. Every one of them booted a browser to verify that, yes, my markdown still rendered.

On a public repo I might never have noticed. This one is private, so the minutes are metered. A Playwright install that hung for over an hour one afternoon was what finally made me read what CI was doing on a paragraph edit. The answer was: far too much.

The trap: don’t skip the whole job
#

The fix looks like four lines. GitHub Actions can filter a workflow by path:

on:
  pull_request:
    paths-ignore:
      - 'content/**'

Add that and content-only PRs skip the workflow. It also jams the merge button.

The problem is branch protection. If build-and-test is a required status check, a PR that never runs it never reports it as passed. The check sits in a permanent “waiting for status to be reported” state, and the merge stays blocked forever. GitHub’s own docs spell this out: a skipped required check is not a passing one.

Workflow-level paths-ignore is therefore a trap for any repo with required checks. The job has to run and report success. What I wanted was narrower: run the job, but skip the expensive steps inside it when they don’t apply.

The fix: gate the steps, not the job
#

dorny/paths-filter runs inside the job and reports which path groups changed. One step after checkout:

- name: Detect changes that require browser E2E
  id: filter
  uses: dorny/paths-filter@v3
  with:
    filters: |
      e2e:
        - 'layouts/**'
        - 'assets/**'
        - 'config/**'
        - 'themes/**'
        - 'tests/**'
        - 'playwright.config.*'
        - 'package.json'
        - 'package-lock.json'
        - '.github/workflows/**'

Then every Playwright-related step carries one condition:

- name: Run Playwright tests
  if: steps.filter.outputs.e2e == 'true'
  run: npx playwright test --project=chromium

The browser install, the cache restore, and the test run are all gated on e2e == 'true'. A prose PR touches none of those paths, so e2e is false and the job walks straight past the browser work. It still runs and still reports green, so branch protection stays satisfied.

The Hugo build and htmltest steps stay unconditional. A typo fix still gets compiled and link-checked. It just doesn’t get a browser.

The permission you’ll hit first
#

My first run died in nine seconds:

Resource not accessible by integration

On a pull request, dorny/paths-filter works out the changed files by asking the GitHub API, and this repo’s default GITHUB_TOKEN couldn’t read pull requests. The fix is one block, scoped to the job that needs it:

permissions:
  contents: read
  pull-requests: read

On push events the action diffs against the previous commit with plain git and never touches the token. Pull requests go through the API, so they need pull-requests: read. Grant it and the failure disappears.

Don’t quietly drop the check you were skipping
#

Cutting the browser tests on content PRs carries a real risk: one of those tests was the only thing guarding a genuine failure mode. Put a shortcode inside a markdown heading and Blowfish renders it in the output as the literal placeholder Hugo keeps internally for a shortcode it never resolved, a token that begins with HAHAHUGO. My Playwright suite asserted that token never shows up on a few key pages. Skip Playwright, and a malformed shortcode in a new post sails through unseen.

That marker is cursed enough that I can’t spell it out in full anywhere on this page. Hugo uses the exact characters as its own internal placeholder, so a page that contains the whole token refuses to build, and the grep below would flag the page as broken anyway. This post keeps the string in two pieces. So does the check.

So I didn’t delete that coverage. I moved it somewhere cheaper. A grep over the built public/ directory catches the same thing in milliseconds, and it runs on every PR, gated by nothing:

- name: Check for unrendered shortcodes
  run: |
    # Hugo emits this marker for a shortcode it could not render. Built in two
    # pieces so the workflow file never holds the whole token.
    marker="HAHAHUGO""SHORTCODE"
    if grep -rl "$marker" public/; then
      echo "::error::Found unrendered shortcode markers in build output"
      exit 1
    fi
    echo "No unrendered shortcodes in build output"

This is stronger than what it replaced. The browser test checked three specific pages; the grep checks the entire built site. Content PRs lost a headless browser and gained wider coverage of the one thing that breaks in prose.

The result
#

A content-only PR now finishes build-and-test in about 15 seconds, down from 85. The first prose PR after the change, coincidentally a batch of blog edits, proved it on the nose.

Most of that 70-second drop is the Chromium that no longer gets installed to inspect a paragraph. On a private repo, where GitHub bills each job rounded up to the minute, that’s a billed minute handed back on every prose PR. The bigger win is quieter: the slowest and flakiest stretch of the pipeline, downloading and booting a browser, no longer runs on the changes that never needed it.

One caveat on the accounting. The 15 seconds is the build-and-test job. The lint job still takes its usual ~25 seconds and now sets the wall-clock for a content PR. I’m fine with that. Markdown linting should run on a post.

Lessons
#

  • For a content-heavy repo, ask what each CI step actually protects against on a prose PR. Browser tests guard rendering and JS; a typo touches neither.
  • paths-ignore at the workflow level skips the entire job, which leaves a required status check pending forever and blocks the merge. Gate the steps inside the job instead.
  • When you stop running an expensive check, find the one failure it really caught and re-cover it cheaply. A grep over the build output beat a headless browser at catching unrendered shortcodes, and covered more pages.
  • dorny/paths-filter needs pull-requests: read on pull-request events; it asks the API for the changed files. Scope the permission to the job, not the whole workflow.
  • The cheapest test is the one you don’t run when it can’t fail.

References
#

Related

My blog publishes one post a day. I haven't touched the deploy in weeks.

··1104 words·6 mins
In July I went three weeks without opening my blog repo. During those weeks it published two posts, on schedule, each one confirmed live by an automated check, and the only reason I know all this is a green history in the Actions tab. The system's single notification channel is a failure email, and it has never fired. This post is the full pipeline, including the parts that only exist because something went wrong on the way here. 🧪 Tested with Hugo 0.164.0 · Cloudflare Pages The one Hugo fact everything hangs on # Hugo skips content dated in the future unless you pass --buildFuture (docs). That single default turns the date field into a release valve. Merge a post dated next Tuesday and production simply does not contain it: not in the sitemap, not in RSS, not at its URL. It sits in main, invisible, until a build happens after its date.

Three layers of secret defense for a public dotfiles repo. One was decorative.

··1190 words·6 mins
My dotfiles repo is public, which means any slip with a credential is permanent. History rewrites do not un-leak a key that a scraper already saw. So the defense cannot be one layer, and the interesting part of layering is not the count of tools. It is that each layer intercepts at a different moment: one before the commit exists, one at the moment of push, one sweeping the entire history in CI. The uncomfortable part, and the reason this post belongs to this series: one of my three layers used to be a decoration.

My diagrams rendered on refresh and vanished on click. The head never loaded.

··1010 words·5 mins
A reader clicking from my homepage to a post with a diagram got a block of raw mermaid source. The same reader pasting that post's URL directly got a rendered diagram. Same page, same build, same browser. The difference was the click, and the bug had been live on this site for months across every diagram, every math formula, and every chart, because I had only ever tested pages by loading them directly. 🧪 Tested with Blowfish 2.10x · htmx 2.0.10 Two features, both reasonable, one collision # This site has htmx’s hx-boost on the body: internal navigation swaps page content in place instead of doing full page loads, which keeps transitions smooth. Separately, the Blowfish theme is smart about heavy libraries: mermaid, KaTeX, and Chart.js bundles are only included on pages that use them, injected into the <head> of exactly those pages.