Featured image of post The build gate these sites never had

The build gate these sites never had

I run three Hugo sites now. This blog, my dad’s B&B, and the vanlife site my wife and I are slowly putting together. All three build with Hugo and deploy to GitLab Pages, and until recently all three had the same blind spot: the build only ever ran on main.

Which means that if I pushed something that broke the build, a typo in a shortcode, a cross-link to a page that doesn’t exist, a theme bump that didn’t take, I found out after it landed. The change was already merged, sitting on the default branch, and the deploy was the thing that fell over. No gate, no warning, nothing telling me on the merge request that the site wouldn’t build. Just a red pipeline on main and a site that had just stopped updating.

Three copies of the same job

I’ve written before about pulling shared CI into components you include rather than copy-paste, and this was a textbook case of why. Each of the three sites had its own hand-rolled pages job: the same pinned hugomods/hugo image, the same GIT_DEPTH: 0, the same nightly rebuild, all written out three times and drifting apart a little more with every tweak. I already had a zensical-pages component for the docs framework I use elsewhere, but nothing covered a plain Hugo build.

So I wrote one: a hugo-pages component in my shared cicd project, and pointed all three sites at it.

The gate they were missing

The interesting part isn’t the deduplication, it’s what the component fixes. It splits the work into two jobs, and that split is the entire reason the component exists.

hugo-build builds the site to public/, and it carries an explicit rule so it runs in merge-request pipelines too:

rules:
  - if: '$CI_COMMIT_TAG'
    when: never
  - if: '"$[[ inputs.mr_gate ]]" == "true"'
    when: on_success

That second line is the bit the old jobs never had. Push a broken Hugo build now and it shows up red on the merge request, where you can see it before merging, instead of sailing onto main and leaving the deploy as the only thing left to fall over. It’s interruptible: true, so pushing a fix cancels the in-flight run rather than queuing behind it. The deploy is a separate job, pages, which only runs where it should:

pages:
  variables:
    GIT_STRATEGY: none
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      when: never
    - if: '$CI_COMMIT_BRANCH == "$[[ inputs.deploy_branch ]]"'
    - if: '$CI_PIPELINE_SOURCE == "schedule"'

It takes the artifact hugo-build already produced and republishes it, with GIT_STRATEGY: none because there’s nothing to clone. Build everywhere, catch the breakage early; deploy only from main (and one other place, which is where it gets interesting). The component is pinned at v0.13.0.

The one component that wants to run on a schedule

Here’s the bit I like. Every other component in my cicd project carries a leading guard that says never run on a schedule, because a nightly Renovate schedule firing a release job or a deploy is exactly the kind of pointless busywork you don’t want.

hugo-pages does the opposite, on purpose. It deploys on schedule, and the reason is sitting in plain sight in the component itself:

# Schedule note: unlike every other component (which carries a
# `schedule → never` guard ...), hugo-pages DELIBERATELY deploys on
# `schedule`. A nightly pipeline schedule is the mechanism by which
# future-dated, non-draft posts go live once their date arrives (Hugo
# excludes future-dated content from `production` builds until then).
# The schedule IS the feature.

When I write a post and date it next Tuesday, Hugo’s production build simply doesn’t include it yet, because its date is in the future. Nothing publishes it on the day except a build that runs on the day and finds that the date has now arrived. That’s the nightly schedule. It’s not noise to be guarded against here, it’s the publish button. The post you’re reading went live exactly this way: written days early, dated forward, and pushed out by a scheduled pipeline the morning its date came up.

One codebase, two flatly opposite rules about the same trigger, and knowing which site wants which is the actual design.

A gate you can switch off

There was one wrinkle. The new MR build gate is the right default for a docs site with contributors, but for these three blogs it was friction. I run them trunk-based: I work on main, I audit locally, I push. A mandatory merge-request build for a workflow that doesn’t really use merge requests is a job that mostly just sits there.

So the gate is a boolean you can turn off:

mr_gate:
  type: boolean
  default: true

Left at its default, hugo-build runs everywhere and the gate is on, which is the safe posture for anyone adopting the component fresh. Set it to false, as this blog does, and hugo-build falls through to running only where it deploys, matching the classic Pages-only-on-main setup the sites had before, but now without three copies of the YAML to maintain. Safe by default, loose by choice, and the choice is one line.

Where it leaves things

A reusable component instead of three drifting copies, a build gate that catches a broken site before it merges, a schedule that publishes tomorrow’s post on time rather than firing for nothing, and an opt-out for the trunk-based sites that don’t want the gate. None of it is clever. It just means the next time I fat-finger a shortcode, I find out on the merge request and not from my dad asking why the B&B site is showing a stack trace.

Built with Hugo
Theme Stack designed by Jimmy