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=chromiumThe 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 integrationOn 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: readOn 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-ignoreat 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
grepover the build output beat a headless browser at catching unrendered shortcodes, and covered more pages. dorny/paths-filterneedspull-requests: readon 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 #
dorny/paths-filter(path-based step gating for GitHub Actions)- Troubleshooting required status checks (GitHub Docs: skipped vs. passing checks)
- Automatic token authentication (GitHub Docs:
GITHUB_TOKENpermissions) - About billing for GitHub Actions (GitHub Docs: per-job, per-minute rounding)