What a Hugo and GitHub Actions Content Pipeline Needs Before It Can Publish Itself

· 11 min read

A static site can be easy to publish and still be difficult to publish safely. The build may pass on a pull request, a scheduled run may be the thing that makes a future-dated post visible, and the branch that hosts the generated site may need write access that the build does not.

This post is for developers maintaining a Hugo site on GitHub who want to understand those boundaries before adding more automation. The promise is concrete: by the end, you can trace this repository’s workflow from an event to a production build, explain why a post with a future date is not published yet, and identify which parts should be tightened before copying the pattern. The scope is one Hugo site, npm-based PostCSS, and GitHub Pages publishing through a public branch. It does not cover custom domains, a multi-site monorepo, preview environments, content review policy, or zero-downtime hosting.

Start with the delivery boundary

The source of truth is the master branch. The generated site is published to a separate public branch. A pull request can build the source, but it must not update the published branch.

That separation answers two different questions:

  1. Can this change produce a valid site? The pull request build answers this.
  2. Should this revision become the site visitors receive? Only a push to master, a scheduled rebuild, or an explicit manual run may answer this in the current workflow.

The workflow lives at .github/workflows/build-and-deploy.yml. It is short enough to read as a complete system:

name: Build and Deploy

on:
  pull_request:
    branches:
      - master
  push:
    branches:
      - master
  schedule:
    - cron: "0 2 * * *"
  workflow_dispatch:

permissions:
  contents: write

concurrency:
  group: deploy
  cancel-in-progress: false

The rest of the job checks out the repository, installs the JavaScript dependencies, installs extended Hugo 0.159.0, builds the site, and runs the deployment action only when the event is not a pull request:

      - name: Build site
        run: hugo --gc --minify

      - name: Deploy to public branch
        if: github.event_name != 'pull_request'
        uses: peaceiris/actions-gh-pages@v4
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_branch: public
          publish_dir: ./public
          force_orphan: true

The conditional is not decoration. Without it, a successful pull request job could have a path to overwrite the branch that GitHub Pages serves. The action’s publish_branch and publish_dir make the output location explicit, while force_orphan means that the published branch keeps only the latest generated commit. That is reasonable for generated output, but it also means that the source branch, not public, is the history to preserve.

The Pages setting is a separate boundary. The workflow can push public; repository settings still need to select the intended source for GitHub Pages. A green workflow is not proof that the Pages configuration points at the branch that was just updated.

What each trigger is allowed to do

The on block describes when the same job runs, not what every run should publish.

TriggerWhat starts itWhat the current job does
pull_request targeting masterA pull request opened or updated for the base branchChecks out the proposed source, installs dependencies, and builds; the deploy step is skipped
push to masterA commit reaches the source branchBuilds and publishes public
schedule at 0 2 * * *A daily cron eventRebuilds the default branch and publishes the result
workflow_dispatchA maintainer starts it manuallyRuns the same build and, because it is not a pull request, publishes the result

GitHub documents scheduled workflows as running from the default branch. That matters here because a scheduled run does not inspect an arbitrary feature branch. The cron expression is also in UTC, and scheduled runs can be delayed during periods of high GitHub load. A daily run is a useful freshness mechanism, not a deadline guarantee.

The concurrency group prevents two runs in this workflow from deploying at the same time. cancel-in-progress: false chooses to let a later run wait rather than cancel an earlier one. That protects the publish boundary, but a stuck or slow run can delay the next one. If the site becomes expensive to build, this setting deserves an explicit operational decision rather than being copied blindly.

The build is a versioned pipeline, not just hugo

This site uses Tailwind CSS through Hugo’s PostCSS integration. The relevant steps are:

env:
  HUGO_VERSION: "0.159.0"

steps:
  - uses: actions/checkout@v4
    with:
      fetch-depth: 0

  - uses: actions/setup-node@v4
    with:
      node-version: "20"
      cache: "npm"

  - run: npm ci

  - uses: peaceiris/actions-hugo@v3
    with:
      hugo-version: ${{ env.HUGO_VERSION }}
      extended: true

  - run: hugo --gc --minify

There are several different kinds of version boundary here:

  • HUGO_VERSION is an exact Hugo release number, and extended: true is required for the asset pipeline used by this site.
  • node-version: "20" asks setup-node for the Node 20 line, not one immutable patch release. It is more explicit than using whatever Node happens to be installed on the runner, but it is not the same as pinning 20.x.y.
  • package-lock.json records the resolved npm dependency graph. npm ci installs from that lock file and is intentionally less forgiving than npm install when the manifest and lock file disagree.
  • @v4, @v3, and the other action tags are moving references. They make upgrades convenient, but a tag is not an immutable supply-chain boundary.

GitHub’s security guidance recommends pinning actions to full-length commit SHAs when immutability is more important than convenience. This repository currently uses major tags, so an action update is part of the trust boundary. A practical compromise is to pin reviewed SHAs and use Dependabot or a deliberate maintenance change to propose updates. Do not silently describe a major tag as a pinned version.

fetch-depth: 0 fetches all history, which is useful when a site or a diagnostic step needs Git metadata. This site does not currently use Git history in its templates, so the deeper checkout has a network and time cost without changing the rendered content today. It may still be a conscious choice for future metadata; the important point is to know why it is present.

--gc enables Hugo’s garbage collection for resources, and --minify reduces the generated output. Neither flag is a content test. They make the production artifact more like the one that will be published, while the templates, front matter, and asset processing still need their own review.

Future dates are a publishing policy

The post date is part of the content contract:

date: 2026-09-25T00:00:00Z
draft: false

Hugo does not include a page whose date or publishDate is in the future in a normal build. That is why a post can be committed and pass the workflow without appearing on the site immediately. The next scheduled build after the date has arrived is what turns this repository’s queued content into published output.

For a local preview, opt into future content explicitly:

hugo server -D --buildFuture

Do not use that command as evidence that production will publish the page today. It intentionally changes the set of pages being built. Conversely, do not treat a normal production build’s omission of a future post as a failed build.

Hugo also does not automatically clear every old file in the destination directory before a build. A stale local public directory can therefore make a preview look newer than the source. A clean local production check should start from generated output that is safe to remove:

rm -rf public resources/_gen
npm ci
hugo --gc --minify

Only remove those paths when they are generated directories in the current checkout. The CI runner starts from a clean workspace, and public/ and resources/_gen/ are ignored by this repository.

Reproduce the smallest useful check

The smallest honest experiment is not a second deployment workflow. It is the existing workflow’s build boundary run with the versions it declares:

node --version       # Node 20.x in CI
npm --version
hugo version         # Hugo 0.159.0, extended
npm ci
hugo --gc --minify
git diff --check

In the September 21, 2026 validation of this repository, Node v20.20.2 and extended Hugo v0.159.0 were used. npm ci completed and hugo --gc --minify completed successfully. The production command intentionally excluded this post because its scheduled date is September 25. The check proves that the current source can be transformed into a production-shaped artifact; it does not prove that a remote runner, Pages configuration, or every link will behave correctly.

For a content change, I would verify the following separately:

  1. Run the normal build and confirm it exits successfully.
  2. Run hugo server -D --buildFuture only when reviewing a future-dated page locally.
  3. Inspect the generated route with the post’s slug rather than assuming a successful build means the page is visible.
  4. On a pull request, confirm that the workflow is green and that no public branch update occurred.
  5. After merging, inspect the deployment run and the published site; the schedule is a second opportunity to publish future content, not a substitute for checking the first deployment.

The repository does not have a separate link checker or content test suite in this workflow. That is a limitation worth stating. A Hugo build catches template, front matter, and asset-processing failures, but it does not catch every broken external URL, an unclear explanation, or a Pages setting pointing at the wrong branch.

Failure modes that are easy to misread

“The pull request passed, but the post is not online”

That is the intended result for an unmerged pull request. It is also the expected result for a post whose front-matter date is still in the future. Check the event type, the post date, the default branch, and the public branch before changing the deployment condition.

“The scheduled run did nothing”

Scheduled workflows run from the default branch and can be delayed. Check that the workflow is enabled, that the repository has recent activity, and that the run’s checkout contains the post. If the date has not arrived in the workflow’s time zone, Hugo will correctly leave the page out.

“The local site contains a page that production does not”

The local server may have been started with --buildFuture or -D, or its destination directory may contain stale generated files. Rebuild from clean generated directories without those flags.

“The build works locally but fails in Actions”

Compare the Node line, Hugo release, Hugo extended mode, lock file, operating system, and environment rather than trying random upgrades. This repository’s workflow intentionally installs npm dependencies with npm ci and does not rely on a globally installed Hugo binary.

“An action changed behavior without a content commit”

A major tag can advance. If that matters for the site, use full-length SHAs for actions, record the update as a reviewed change, and keep the exact Hugo and dependency versions visible. Reproducibility is not achieved by pinning only the application runtime.

Security and deployment trade-offs

The current workflow has a useful deployment condition, but its permission declaration deserves scrutiny:

permissions:
  contents: write

It is declared at workflow scope so the deployment action can push public. The build job therefore has a broader token permission than it needs, including on paths where it only reads source and generates files. A stronger design would separate read-only validation from a deploy job that alone receives contents: write, transfer the build artifact between them, and keep the deploy job gated on the trusted event and branch. That design adds YAML and artifact handling, so this post documents the current boundary rather than pretending the simple version is the strongest possible one.

Other safeguards follow from the same threat model:

  • Do not move the deploy step to pull_request_target merely to make a fork build publishable. That event runs with the base repository’s privileges and requires a separate review of untrusted code.
  • Treat workflow, package manifest, lock file, and Hugo template changes as executable supply-chain inputs. Review them with the same care as application code.
  • Keep secrets out of Markdown, front matter, generated output, and build logs. The public site should not need a deployment secret; the action uses the repository-provided GITHUB_TOKEN.
  • Keep the Pages branch separate from source and make the publish branch explicit. A generated branch is disposable output, not a place to edit content.
  • Consider a job timeout and a narrower job-level permission when the site or its dependencies grow. A hung build consumes runner time and can delay future-dated publishing.

The current site has no cloud service in the build itself. Its costs are runner minutes, dependency and Hugo downloads, repository storage for source and generated history, and the traffic required to serve the static site. Full-history checkout, daily rebuilds, and publishing an orphan commit are all trade-offs that are cheap at this size but should remain visible as the archive grows.

A decision framework before adding more automation

Before adding a new trigger or deployment action, ask:

  1. What event is trusted to publish? Keep review events and production events distinguishable.
  2. Which exact tool versions must agree? Pin Hugo, define the Node support line, and keep the lock file in sync.
  3. What does a green build prove? Name the checks it performs and the checks it does not.
  4. What permission does each job need? Prefer read-only validation and a narrowly gated write step.
  5. What happens to future content? Use front-matter dates deliberately and make preview flags explicit.
  6. Where is the generated artifact served? Verify the Pages setting and the publish branch independently.
  7. How is an action updated? Choose reviewed SHAs or an explicit tag-maintenance policy.

Automation is ready to publish itself when those answers are written down and observable in the run. The goal is not the shortest workflow. It is a workflow where a failed build, a skipped deployment, and an absent future post each have an explainable cause.

Primary documentation

These are the sources used for the version-sensitive behavior and security decisions in this post, checked September 21, 2026: