Install Hugo at a deliberate, hash-verified version instead of taking whatever alpine ships #26

Closed
opened 2026-08-09 16:17:40 +02:00 by clawbot · 1 comment
Collaborator

Decision

From @sneak: the Hugo version must be deliberate. Use a container or go install by hash. Do not use distro Hugo.

This narrows the resolution of #19. That issue settled that apk packages on a
digest-pinned base are acceptable for build-time conveniences, and that stands
for git, make, nodejs, and npm. Hugo is different: it is the tool that
produces the published artifact, so its version is a property of the output,
not of the build environment.

Current state

script/bootstrap line 67:

if missing hugo; then pkg_install hugo hugo hugo hugo; fi

Whatever the distro serves. On the pinned alpine 3.21 that is Hugo
0.139.0+extended
; upstream current is 0.164.0. So the site is built by a
Hugo roughly two years behind, chosen by nobody, and it changes silently
whenever the base image digest is bumped.

It already has a visible effect: #18 exists because a current Hugo emits a
languageCode deprecation warning that CI's 0.139.0 cannot see. CI is green on
a Hugo old enough not to know about the deprecation while a developer with a
current Hugo sees it locally.

Extended is not required — verified

Checked before specifying options, because it determines whether go install
is viable at all (Hugo extended needs CGO):

  • No SCSS/Sass anywhere: no .scss/.sass files, no resources.ToCSS, no
    PostCSS.
  • CSS is delivered by {{ readFile "themes/loravega/static/css/style.css" | safeCSS }} in baseof.html — plain CSS inlined, no asset pipeline.
  • No image processing (.Resize/.Fill/.Fit/images.* all absent).

So standard Hugo is sufficient. The current image happening to carry
+extended is incidental, not a requirement. Note this explicitly in the PR so
nobody later assumes extended is needed.

Options

Option A — go install at a pinned version (recommended). In
script/bootstrap:

go install github.com/gohugoio/hugo@vX.Y.Z

Go verifies the module against sum.golang.org, so this is genuinely
hash-verified rather than version-pinned — it is the mechanism
REPO_POLICIES.md already names for Go dependencies, and it satisfies "by
hash" without hand-maintaining a sha256. Costs: script/bootstrap gains a Go
toolchain (itself from apk, which #19 permits), and the build compiles Hugo
rather than fetching a binary, which will add time to a cold build. Set
CGO_ENABLED=0 explicitly since extended is not needed.

Option B — a Hugo container pinned by digest. Reference an official/known
Hugo image by @sha256:... with the canonical # name version, YYYY-MM-DD
comment, and use it as a build stage or as the workflow container. Strongest
supply-chain position and no compile cost. Costs: it reintroduces a second
pinned base image alongside the alpine one — which is exactly what #7 removed
when it dropped klakegg/hugo:ext-alpine — and the deploy build job and the
Dockerfile would need to agree on it.

My recommendation: Option A. It keeps one pinned base image, uses the
checksum-database mechanism the policy already prefers, and makes the Hugo
version a single visible constant in script/bootstrap. Option B is defensible
if cold-build time turns out to matter, but reintroducing a second image is a
real cost and #7 was specifically about consolidating onto one.

Either way, the chosen Hugo version must appear as an explicit, commented
constant
, not be implied.

Definition of done

  1. script/bootstrap installs a specific, explicitly-stated Hugo version by a
    hash-verified mechanism. No pkg_install hugo.
  2. The version is stated as a constant with a # hugo vX.Y.Z, YYYY-MM-DD
    comment. hugo version in the built image reports exactly it.
  3. Non-extended is used deliberately, with a comment recording that no SCSS or
    image processing exists in this site.
  4. Rendered output is byte-identical. Build public/ before and after and
    diff it. Any difference must be explained and justified in the PR, not
    waved through — this is a two-year version jump.
  5. make check and script/cibuild pass, with the check layer confirmed
    genuinely executed and not CACHED (see #23).
  6. Cold-build time is reported. If Option A pushes the Docker build near the
    5-minute policy budget, say so.
  7. TODO.md updated in the same commit.

Critical — this touches the live deploy path

.gitea/workflows/deploy.yml's build job runs script/bootstrap inside the
pinned alpine container. Changing how Hugo is installed changes the
production deploy path.
This repo has already taken one outage from a
deploy.yml change that passed two adversarial reviews (#7), and the only
thing that actually caught it was making the job executable pre-merge.

So this work must use the same approach:

  • Add a temporary branch trigger to deploy.yml's on.push.branches so the
    build job really executes under act_runner.
  • The if: github.ref_name == 'main' guard on the deploy job is already in
    place and must stay, so branch runs cannot reach Cloudflare.
  • Read the result from the commit-status API
    (/api/v1/repos/sneak/lora.vegas/commits/<sha>/status) — readable by
    clawbot even though the Actions jobs/logs API 403s.
  • Iterate until the build job is green on the branch.
  • Remove the temporary trigger in the final commit.
  • Confirm no functional change to deploy.yml between the runner-verified
    commit and the merge candidate. "It passed on the branch" and "it will pass
    on main" are different claims.

A green script/cibuild does not cover this: docker build runs RUN
under /bin/sh with no Actions runtime.

Sequencing

  • Moving to a current Hugo will surface #18's languageCode deprecation
    warning in CI
    , where it is currently invisible. Either fix #18 in the same
    branch or land #18 first; do not merge this and leave CI newly noisy.
  • #13 (taxonomy warning) is unaffected but is the other outstanding warning.
  • #25 (--panicOnWarning) must come after this, #18, and #13, or the gate
    goes red immediately.
  • Interacts with #23: verifying anything here needs a genuinely-executed check
    layer.

Out of scope

  • Any rendering or content change. If the version jump changes output, that is
    a finding to report, not something to accommodate by editing content.
  • Changing how git, make, nodejs, or npm are installed — #19 settled
    that apk is fine for those.
## Decision From @sneak: **the Hugo version must be deliberate. Use a container or `go install` by hash. Do not use distro Hugo.** This narrows the resolution of #19. That issue settled that `apk` packages on a digest-pinned base are acceptable for build-time conveniences, and that stands for `git`, `make`, `nodejs`, and `npm`. Hugo is different: it is the tool that produces the published artifact, so its version is a property of the output, not of the build environment. ## Current state `script/bootstrap` line 67: ```sh if missing hugo; then pkg_install hugo hugo hugo hugo; fi ``` Whatever the distro serves. On the pinned alpine 3.21 that is **Hugo 0.139.0+extended**; upstream current is **0.164.0**. So the site is built by a Hugo roughly two years behind, chosen by nobody, and it changes silently whenever the base image digest is bumped. It already has a visible effect: **#18** exists because a current Hugo emits a `languageCode` deprecation warning that CI's 0.139.0 cannot see. CI is green on a Hugo old enough not to know about the deprecation while a developer with a current Hugo sees it locally. ## Extended is not required — verified Checked before specifying options, because it determines whether `go install` is viable at all (Hugo extended needs CGO): - No SCSS/Sass anywhere: no `.scss`/`.sass` files, no `resources.ToCSS`, no PostCSS. - CSS is delivered by `{{ readFile "themes/loravega/static/css/style.css" | safeCSS }}` in `baseof.html` — plain CSS inlined, no asset pipeline. - No image processing (`.Resize`/`.Fill`/`.Fit`/`images.*` all absent). So **standard Hugo is sufficient**. The current image happening to carry `+extended` is incidental, not a requirement. Note this explicitly in the PR so nobody later assumes extended is needed. ## Options **Option A — `go install` at a pinned version (recommended).** In `script/bootstrap`: ```sh go install github.com/gohugoio/hugo@vX.Y.Z ``` Go verifies the module against `sum.golang.org`, so this is genuinely hash-verified rather than version-pinned — it is the mechanism `REPO_POLICIES.md` already names for Go dependencies, and it satisfies "by hash" without hand-maintaining a sha256. Costs: `script/bootstrap` gains a Go toolchain (itself from apk, which #19 permits), and the build compiles Hugo rather than fetching a binary, which will add time to a cold build. Set `CGO_ENABLED=0` explicitly since extended is not needed. **Option B — a Hugo container pinned by digest.** Reference an official/known Hugo image by `@sha256:...` with the canonical `# name version, YYYY-MM-DD` comment, and use it as a build stage or as the workflow container. Strongest supply-chain position and no compile cost. Costs: it reintroduces a second pinned base image alongside the alpine one — which is exactly what #7 removed when it dropped `klakegg/hugo:ext-alpine` — and the deploy build job and the `Dockerfile` would need to agree on it. **My recommendation: Option A.** It keeps one pinned base image, uses the checksum-database mechanism the policy already prefers, and makes the Hugo version a single visible constant in `script/bootstrap`. Option B is defensible if cold-build time turns out to matter, but reintroducing a second image is a real cost and #7 was specifically about consolidating onto one. Either way, **the chosen Hugo version must appear as an explicit, commented constant**, not be implied. ## Definition of done 1. `script/bootstrap` installs a specific, explicitly-stated Hugo version by a hash-verified mechanism. No `pkg_install hugo`. 2. The version is stated as a constant with a `# hugo vX.Y.Z, YYYY-MM-DD` comment. `hugo version` in the built image reports exactly it. 3. Non-extended is used deliberately, with a comment recording that no SCSS or image processing exists in this site. 4. **Rendered output is byte-identical.** Build `public/` before and after and diff it. Any difference must be explained and justified in the PR, not waved through — this is a two-year version jump. 5. `make check` and `script/cibuild` pass, with the check layer confirmed genuinely executed and not `CACHED` (see #23). 6. Cold-build time is reported. If Option A pushes the Docker build near the 5-minute policy budget, say so. 7. `TODO.md` updated in the same commit. ## Critical — this touches the live deploy path `.gitea/workflows/deploy.yml`'s `build` job runs `script/bootstrap` inside the pinned alpine container. **Changing how Hugo is installed changes the production deploy path.** This repo has already taken one outage from a `deploy.yml` change that passed two adversarial reviews (#7), and the only thing that actually caught it was making the job executable pre-merge. So this work **must** use the same approach: - Add a temporary branch trigger to `deploy.yml`'s `on.push.branches` so the `build` job really executes under `act_runner`. - The `if: github.ref_name == 'main'` guard on the `deploy` job is already in place and must stay, so branch runs cannot reach Cloudflare. - Read the result from the commit-status API (`/api/v1/repos/sneak/lora.vegas/commits/<sha>/status`) — readable by `clawbot` even though the Actions jobs/logs API 403s. - Iterate until the `build` job is green on the branch. - Remove the temporary trigger in the final commit. - Confirm no functional change to `deploy.yml` between the runner-verified commit and the merge candidate. "It passed on the branch" and "it will pass on main" are different claims. A green `script/cibuild` does **not** cover this: `docker build` runs `RUN` under `/bin/sh` with no Actions runtime. ## Sequencing - Moving to a current Hugo will **surface #18's `languageCode` deprecation warning in CI**, where it is currently invisible. Either fix #18 in the same branch or land #18 first; do not merge this and leave CI newly noisy. - #13 (taxonomy warning) is unaffected but is the other outstanding warning. - #25 (`--panicOnWarning`) must come **after** this, #18, and #13, or the gate goes red immediately. - Interacts with #23: verifying anything here needs a genuinely-executed check layer. ## Out of scope - Any rendering or content change. If the version jump changes output, that is a finding to report, not something to accommodate by editing content. - Changing how `git`, `make`, `nodejs`, or `npm` are installed — #19 settled that apk is fine for those.
Author
Collaborator

Implementation plan

Taking Option A (go install), as recommended. #18 rides in the same
branch, committed on top, per its second sequencing comment.

Version: hugo v0.164.0 (2026-07-06) — the current stable release, per
proxy.golang.org/github.com/gohugoio/hugo/@latest.

One thing the issue body did not anticipate

hugo v0.164.0's go.mod declares go 1.26.0. Alpine 3.21 ships go
1.23.9
, and the alpine go package builds with GOTOOLCHAIN=local, so a
bare go install fails outright:

github.com/gohugoio/hugo@v0.164.0 requires go >= 1.26.0
(running go 1.23.9; GOTOOLCHAIN=local)

Fix: name the toolchain explicitly as a second commented constant
(GOTOOLCHAIN=go1.26.5). Go then fetches golang.org/toolchain through the
module proxy and verifies it against sum.golang.org exactly like any other
module, so this stays hash-verified end to end and the Go version becomes
deliberate too rather than inherited from the base image. Measured cold:
apk add go 4s + go install 41s.

script/bootstrap changes

  • Two commented constants: HUGO_VERSION and HUGO_GOTOOLCHAIN, each with
    the canonical # name version, YYYY-MM-DD form.
  • pkg_install hugo hugo hugo hugo is replaced by: install a Go toolchain
    from the package manager (#19 permits apk for build-time conveniences), then
    CGO_ENABLED=0 GOTOOLCHAIN=... go install github.com/gohugoio/hugo@$HUGO_VERSION
    into a temp GOBIN, then install -m 0755 the binary into
    /usr/local/bin.
  • Why /usr/local/bin and not the default GOPATH/bin: the Dockerfile's
    RUN make check and deploy.yml's script/test step each start a fresh
    shell
    , so an install location that is not already on the default PATH
    would not be found. HUGO_BIN_DIR stays overridable for unprivileged
    installs.
  • CGO_ENABLED=0 explicitly, with a comment recording the verified finding
    that standard (non-extended) Hugo is sufficient for this site: no
    .scss/.sass, no resources.ToCSS, no PostCSS, no image processing; CSS
    is inlined via readFile in baseof.html. The +extended on the current
    apk build is incidental.
  • The idempotency guard becomes version-aware rather than
    missing hugo — an old hugo already on PATH must be replaced, not
    accepted.

Verification

  • Build public/ three ways and diff: (a) main + apk hugo 0.139.0, (b) new
    hugo 0.164.0 + unchanged hugo.toml, (c) 0.164.0 + locale. (b) isolates
    the version jump from the config change. Any difference gets reported and
    justified, not accommodated.
  • RSS <language> asserted to still read en-us on the Hugo the build
    actually uses.
  • deploy.yml gets a temporary branch trigger so the build job really runs
    under act_runner; results read from the commit-status API; the trigger is
    removed in the final commit and the deploy job's
    if: github.ref_name == 'main' guard stays untouched throughout.
  • Cold script/cibuild time reported, with the check layer confirmed
    genuinely executed rather than CACHED (#23).

Two commits, one per issue.

## Implementation plan Taking **Option A** (`go install`), as recommended. #18 rides in the same branch, committed on top, per its second sequencing comment. **Version: hugo v0.164.0 (2026-07-06)** — the current stable release, per `proxy.golang.org/github.com/gohugoio/hugo/@latest`. ### One thing the issue body did not anticipate `hugo v0.164.0`'s `go.mod` declares `go 1.26.0`. Alpine 3.21 ships **go 1.23.9**, and the alpine `go` package builds with `GOTOOLCHAIN=local`, so a bare `go install` fails outright: github.com/gohugoio/hugo@v0.164.0 requires go >= 1.26.0 (running go 1.23.9; GOTOOLCHAIN=local) Fix: name the toolchain explicitly as a second commented constant (`GOTOOLCHAIN=go1.26.5`). Go then fetches `golang.org/toolchain` through the module proxy and verifies it against `sum.golang.org` exactly like any other module, so this stays hash-verified end to end and the Go version becomes deliberate too rather than inherited from the base image. Measured cold: `apk add go` 4s + `go install` 41s. ### `script/bootstrap` changes - Two commented constants: `HUGO_VERSION` and `HUGO_GOTOOLCHAIN`, each with the canonical `# name version, YYYY-MM-DD` form. - `pkg_install hugo hugo hugo hugo` is replaced by: install a Go toolchain from the package manager (#19 permits apk for build-time conveniences), then `CGO_ENABLED=0 GOTOOLCHAIN=... go install github.com/gohugoio/hugo@$HUGO_VERSION` into a temp `GOBIN`, then `install -m 0755` the binary into `/usr/local/bin`. - Why `/usr/local/bin` and not the default `GOPATH/bin`: the `Dockerfile`'s `RUN make check` and `deploy.yml`'s `script/test` step each start a **fresh shell**, so an install location that is not already on the default `PATH` would not be found. `HUGO_BIN_DIR` stays overridable for unprivileged installs. - `CGO_ENABLED=0` explicitly, with a comment recording the verified finding that **standard (non-extended) Hugo is sufficient** for this site: no `.scss`/`.sass`, no `resources.ToCSS`, no PostCSS, no image processing; CSS is inlined via `readFile` in `baseof.html`. The `+extended` on the current apk build is incidental. - The idempotency guard becomes version-aware rather than `missing hugo` — an old hugo already on `PATH` must be replaced, not accepted. ### Verification - Build `public/` three ways and diff: (a) `main` + apk hugo 0.139.0, (b) new hugo 0.164.0 + unchanged `hugo.toml`, (c) 0.164.0 + `locale`. (b) isolates the version jump from the config change. Any difference gets reported and justified, not accommodated. - RSS `<language>` asserted to still read `en-us` on the Hugo the build actually uses. - `deploy.yml` gets a temporary branch trigger so the `build` job really runs under `act_runner`; results read from the commit-status API; the trigger is removed in the final commit and the `deploy` job's `if: github.ref_name == 'main'` guard stays untouched throughout. - Cold `script/cibuild` time reported, with the check layer confirmed genuinely executed rather than `CACHED` (#23). Two commits, one per issue.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/lora.vegas#26