Part 5 of 5. ← Part 1: The Why · ← Part 2: The Architecture · ← Part 3: AI Curation Workflows · ← Part 4: Agents and Weekly Audits
The previous four parts built the system and the habits around it. Capture works, curation is mostly automated, and a weekly audit keeps quality from drifting. But none of it matters if publishing is fragile or if your private content leaks.
This post is about the CI/CD pipeline. Specifically: how a single push to main runs the full build, generates any missing featured images, indexes content for search, and deploys a clean public site without ever touching your inner-mind drafts.
What the pipeline does and why it is structured this way
The workflow lives in gh-pages.yml and triggers on two events: a push to main, and a manual workflow_dispatch. The manual trigger is important. It means you can re-run the build without making a dummy commit when something fails or when you want to regenerate an image.
The workflow has two jobs: build and deploy. Build does all the heavy lifting on ubuntu-latest. Deploy takes the resulting artifact and publishes it to GitHub Pages. Keeping them separate means a failed build does not touch the live site.
Here is the full sequence of what runs inside the build job:
- Install Hugo Extended (pinned via
.deb) - Install Dart Sass (via snap)
npm cito install Node.js dependencies (Playwright, js-yaml)- Install Playwright Chromium with OS dependencies
- Restore Hugo build cache
- Install Python 3.11 and
python-frontmatter - Build the connection graph (
python scripts/build-graph.py) - Generate missing featured images
hugo --gc --minify --baseURL ...- Install Pagefind globally and run the search index
- Save Hugo build cache
- Upload the Pages artifact
Six meaningful stages, four of which are preparation. The actual Hugo build is step nine. Everything before it is setup; everything after it is post-processing.
Pinning Hugo and keeping builds reproducible
Hugo has a long history of breaking changes between minor versions. A site that builds today can fail silently next month if the version changes.
The pipeline installs Hugo Extended from a pinned .deb file rather than from apt or a version range. That means every build uses exactly the same Hugo binary as the last one. When you upgrade Hugo, you do it intentionally, not by accident.
Same principle for Dart Sass. This project uses SCSS with custom variables and dark-mode pairs. Sass compilation is not stable across all versions, and a rogue compiler upgrade can break the entire stylesheet silently.
Pin your tools. Upgrade deliberately. Never accept “latest” from a package manager in CI.
How featured image generation fits into CI
The featured image step is the newest addition to the pipeline. Here is what it does.
The build script loops over every content/blog/*/ directory. For each post, it checks two conditions:
- Does the frontmatter include a
card_hookfield? - Does a
featured-card.pngor legacyfeatured-card.webpfile not yet exist in that directory?
If both are true, it runs node scripts/generate_featured_image.js for that post.
The script reads card_hook, card_stat, card_stat_label, and card_tag from frontmatter. It uses Playwright with a headless Chromium browser to screenshot a styled HTML card at 1200×630 pixels, saves it as featured-card.png in the post directory, and patches the image: field in the frontmatter.
The check-before-run logic makes the entire step idempotent. A post with an existing card is skipped. Re-running the pipeline on an unchanged repo does nothing except confirm that no new cards are needed.
Playwright is a heavyweight dependency. Chromium is not a small install. But the step is incremental, so on most runs it processes zero posts. The overhead is only paid when a new post needs a card.
Why not generate images locally?
You can, and the blog-writer workflow does exactly that during post creation. CI is the safety net. If you wrote a post, set card_hook, and forgot to run the generation script before committing, CI catches it and generates the card automatically. Local generation is the happy path. CI generation is the fallback.
The two-environment design in CI
The pipeline sets HUGO_ENVIRONMENT: production as a build environment variable. That environment maps to config/_default/hugo.yaml, which has buildDrafts: false.
Posts marked draft: true are your inner mind. They never leave your machine unless you explicitly change the environment. The public site only ever sees draft: false content.
The private environment (hugo server --environment private) reads config/private/hugo.yaml, which sets buildDrafts: true. That environment is purely local. It does not exist in CI. It cannot exist in CI, because GitHub Actions has no access to your local config override.
The boundary between inner and outer mind is enforced by the build environment, not by remembering to exclude things.
This is the cleanest thing about the whole design. You do not need a separate repository, a separate branch, or a complex filtering step. The draft flag does the work. The environment setting applies it. CI only knows about production.
Keeping CI lean
A few practical rules that keep the pipeline fast and predictable:
Cache the Hugo build directory. Hugo’s incremental build reuses previously rendered pages. The cache restore and save steps wrap the Hugo build, so unchanged content does not re-render on every push. For a site with hundreds of posts, this is the difference between a 90-second build and a 15-second one.
Use workflow_dispatch for manual re-runs. When a featured image fails to generate or you change a card stat, you need a way to re-trigger the pipeline without pushing a change. workflow_dispatch gives you a button in the GitHub Actions UI.
Keep dependencies explicit. The pipeline installs playwright, js-yaml, and python-frontmatter explicitly, all pinned in package.json or requirements.txt. If a new dependency appears in a script, it goes into the explicit install list. Nothing is assumed to exist on the runner.
Do not add steps for things that can run locally. The build graph, the search index, and the featured images all run in CI because they produce artefacts that need to be in the deployed site. Linting and spell-checking can run locally or in a separate PR check workflow. Keep the deploy pipeline focused on producing the final output.
What to do now
If you are building a similar system, here are three concrete actions:
Audit your current pipeline for pinned versions. Check every
hugoandsassinstall. Replace floating version references with exact pinned versions. Start with Hugo.Add a
workflow_dispatchtrigger if your pipeline does not have one. It costs nothing and saves a dummy-commit every time you need to re-run something.Test the draft boundary explicitly. Push a post with
draft: true, let CI build, and verify it does not appear in the deployed site. Do this once and document the result. Do not rely on remembering how the config works.
The pipeline is the last thing between your notes and the public internet. It should be boring, reliable, and explicit. If you have to think hard about what it will publish, it is not doing its job.
