0.164.0 · Cloudflare PagesThe 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:
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; fiProduction 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 -rfbefore any build you plan to trust.
References#
- Hugo configuration: buildFuture
- Cloudflare Pages deploy hooks
- Cloudflare Pages build configuration and branch variables
- GitHub Actions: schedule event
- The workflow itself:
.github/workflows/scheduled-publish.yamlin this site’s repo, as shipped
