Skip to main content
  1. Posts/

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

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

What I Learned from How Claude Code's Creator Uses Claude Code

··1529 words·8 mins
Boris Cherny created Claude Code. When he shared how he actually uses it day-to-day, the setup was surprisingly simple. I went through every tip, tried most of them, and have opinions about all of them. The original thread is on Boris’s X account. A good companion site is howborisusesclaudecode.com which compiles everything in one place.

Five Stream Deck keys, N Claude sessions: LRU that keeps the order I see

A Stream Deck has five session keys. I usually have six or seven Claude Code sessions running. When a new one shows up, the muscle memory test isn't "does the right session get evicted", it is "do the four survivors stay on the keys they were already on." Building ClaudeDeck · Part 3 of 10 1 2 3 4 5 6 7 8 9 10 🧪 Tested with Claude Code 2.1.x · macOS (Two bits of context for anyone new to the stack: Stream Deck is Elgato’s USB grid of programmable LCD keys, and a “session” here is a single Claude Code conversation: claude running in one terminal tab, with its own working directory, its own context window, its own history. LRU stands for “least-recently used,” the standard cache-eviction policy: when you need to make room, drop the entry nobody has touched in the longest time.)

TCC pins your Accessibility grant to a cdhash. Every rebuild breaks it.

··1734 words·9 mins
My daemon's preflight log said `osascript is not allowed assistive access. (-1719)`. System Settings disagreed: the entry was right there, toggled on. Spoiler: ad-hoc codesigning pins TCC's designated requirement to the binary's cdhash, and `bun build --compile` produces a different cdhash on every rebuild. Building ClaudeDeck · Part 8 of 10 1 2 3 4 5 6 7 8 9 10 I’m building a Stream Deck plugin called ClaudeDeck (Stream Deck is Elgato’s little USB grid of programmable keys with LCD displays under each one). The plugin talks to a background daemon (a long-running process that starts at login and waits for events), and that daemon needs to call System Events via AppleScript to switch Ghostty tabs (Ghostty is my terminal emulator) whenever I press a Stream Deck key. macOS gates that capability, automating other apps, through System Settings → Privacy & Security → Accessibility, the pane you’ve probably toggled for tools like Rectangle or BetterTouchTool. On first install I added the daemon, toggled it on, and got back to work.