Skip to main content
  1. Posts/

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

··1104 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
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.

Which means publishing needs exactly one ingredient beyond git: something that rebuilds the site every day. Cadence stops being a scheduling problem and becomes a frontmatter field. I queue posts two days apart, or daily, or twice daily, by typing different dates, and the infrastructure never changes. That is my strong opinion baked into this design: release cadence belongs in content, not in cron. The cron’s only job is to notice.

The daily self-gating check
#

A GitHub Action runs every day at 16:07 UTC and gates itself:

yes

no

all 200

any fail

daily cron 16:07 UTC

compute every post
due today from frontmatter

all of them
already live?

exit, ~1 minute of runner time

curl the Cloudflare
deploy hook

poll each due URL
until HTTP 200, 5 tries

dispatch mirror rebuild

fail the workflow,
GitHub emails me

yes

no

all 200

any fail

daily cron 16:07 UTC

compute every post
due today from frontmatter

all of them
already live?

exit, ~1 minute of runner time

curl the Cloudflare
deploy hook

poll each due URL
until HTTP 200, 5 tries

dispatch mirror rebuild

fail the workflow,
GitHub emails me

The gate matters for cost and for noise. On a day with nothing due, the job checks one URL and exits inside a minute. On a publish day, it triggers a Cloudflare Pages deploy hook (a bare URL that a curl -X POST hits; the secret lives in the repo settings), waits out the build, then polls every due post’s URL until it serves 200.

The verification step is the part I would defend in an argument. A scheduler that fires a webhook and walks away is not a publishing system, it is a hope. The failure modes it leaves open are all silent: a build that breaks on a bad shortcode, a post whose slug does not match its URL, a CDN that serves the old build. Polling the actual public URL closes all of them at once, and wiring the failure into the workflow’s exit code means GitHub’s own notification machinery becomes my pager. A pipeline should only be able to contact me with bad news. Green runs are nobody’s business.

Previews, or how to see a post that does not exist yet
#

Future posts being invisible in production creates an obvious problem: how do you proofread one? The answer lives in Cloudflare’s build command, which can branch on CF_PAGES_BRANCH:

if [ "$CF_PAGES_BRANCH" = "main" ]; then hugo --gc --minify; else hugo --gc --minify --buildFuture -D; fi

Production stays strict. Every pull request’s preview URL builds the future and the drafts, so the whole queue is reviewable before merge, rendered exactly as it will ship. Previews carry an X-Robots-Tag: noindex header on Pages, so none of it leaks into search.

Scar tissue
#

Three things in the pipeline exist because of specific incidents, and they are the part worth stealing.

Never link a published post to a queued one. The target does not exist in production yet, so the link 404s for real readers. My CI runs htmltest on the production-shaped build, which turns this mistake into a red check instead of a live broken link. The constraint shapes writing order: queue posts may link backward, never forward.

A local --buildFuture build leaves land mines. Hugo does not clean public/ between builds. The first time I built with --buildFuture locally and then rebuilt normally, the future post was still sitting in the output directory, and I spent a genuinely alarmed few minutes believing my release valve leaked. It does not. rm -rf public before the build you intend to trust; the CDN never has this problem because every deploy starts from a fresh clone.

Verify every due post, not the newest one. The first version of the verifier checked only the most recently dated post. Fine at one post per day; wrong the day two posts share a date, because the second one would ship unwatched. The current version collects everything due today and polls each URL. Capacity limits have a way of becoming correctness bugs the moment you grow into them.

What this costs
#

Roughly a minute of GitHub Actions time on quiet days and about five on publish days, a Cloudflare deploy hook (free), and one repository secret. The mirror site rebuild is dispatched only after a successful publish rather than on its own schedule, which keeps a backup deployment from burning minutes re-building unchanged content. For a queue that has run unattended since mid-July, I consider that price roughly zero.

This is the third post in an accidental series about making this blog run itself; the CI path filter that keeps content-only PRs cheap came first, and the canonical domain cleanup made the URLs the verifier polls actually mean something.

Lessons
#

  • Release cadence belongs in frontmatter, not in cron. A daily self-gating check plus future dates gives you any schedule you can type.
  • A scheduler without verification is a hope. Poll the public URL and wire failure into the exit code, so the platform’s notifications become your pager.
  • Design the notification channel to carry only bad news. A system that can ping you with success will train you to ignore it.
  • Build the multi-item case before you need it. “Check the newest one” is correct until the first shared date, and nothing warns you when you cross that line.
  • Hugo does not clean public/ between builds. The stale-artifact scare is a rite of passage; rm -rf before any build you plan to trust.

References
#

Related

My og:image URLs were broken for months. baseURL was the culprit.

··669 words·4 mins
Paste one of my post links into a social preview and the card comes up with no image. The site itself renders fine, every page, every browser. The culprit was one character in `hugo.toml`: `baseURL = "/"`, which quietly turns every absolute URL the site emits into a relative one that only a browser can love. 🧪 Tested with Hugo 0.163.3 · Blowfish 2.104 The symptom # Share cards without images, that was the visible part. View source on any page and the metadata told the fuller story:

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

··1184 words·6 mins
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.

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.