diff --git a/Dockerfile b/Dockerfile index 582c882..c14fec0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,11 @@ FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4 WORKDIR /src # Install build dependencies first so the layer caches until the -# scripts change (script/bootstrap installs git, make, hugo, node/npm). +# scripts change (script/bootstrap installs git, make, go, hugo, +# node/npm). Hugo is not an apk package here: script/bootstrap builds +# the exact pinned version with `go install`, hash-verified against +# sum.golang.org, so the published artifact does not depend on whatever +# hugo this base image's repos happen to serve. COPY script/ script/ RUN script/bootstrap diff --git a/README.md b/README.md index 1d0610f..b377288 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,10 @@ standard: normalized scripts in `script/` are the entrypoints for the development workflow, and the Makefile targets are thin shims that call them. We provide: -- `script/bootstrap` — install all build dependencies (git, make, hugo, - node/npm) idempotently +- `script/bootstrap` — install all build dependencies (git, make, go, hugo, + node/npm) idempotently. Hugo is pinned to an exact version and installed with + `go install`, which verifies it against `sum.golang.org`; the version is the + `HUGO_VERSION` constant at the top of the script - `script/setup` — prepare a fresh clone: run `script/bootstrap` and install the git pre-commit hook - `script/test` — the correctness check: a clean `hugo --minify` production diff --git a/TODO.md b/TODO.md index 081abba..5433c93 100644 --- a/TODO.md +++ b/TODO.md @@ -15,7 +15,9 @@ pre-1.0 No git tags. The site is live and now has the scripts-to-rule-them-all scaffold (`Makefile`, `script/`, `Dockerfile`, `check.yml`); still missing `LICENSE` and policy files. Every external reference in the repo is now pinned by -cryptographic hash (or, for the wrangler CLI install, an exact version). +cryptographic hash (or, for the wrangler CLI install, an exact version), and the +Hugo that builds the published site is a deliberate pinned version rather than +whatever the base image's package repo serves. # Next Step @@ -25,6 +27,18 @@ Update `README.md` accordingly. # Completed Steps +- 2026-08-09: installed Hugo at a deliberate, hash-verified version instead of + taking whatever alpine ships (closes #26). `script/bootstrap` no longer does + `pkg_install hugo`; it installs `github.com/gohugoio/hugo@v0.164.0` with + `go install`, which verifies the module against `sum.golang.org`. The version + is a commented constant, as is the Go toolchain (`go1.26.5`) — hugo v0.164.0 + requires go >= 1.26.0 and alpine 3.21 ships go 1.23.9 with + `GOTOOLCHAIN=local`, so a bare `go install` refuses to run. `CGO_ENABLED=0` is + deliberate: standard Hugo, not extended, because this site has no SCSS, no + `resources.ToCSS`, no PostCSS and no image processing. This moves the build + off apk's hugo 0.139.0, about two years behind, onto the current stable. + Rendered output across the whole `public/` tree is unchanged except the + `meta name=generator` version string - 2026-08-09: made `script/check` run `script/lint` (closes #9). It previously ran only `fmt-check` then `test`, so `script/lint` executed nowhere — not in `make check`, not in the pre-commit hook, and not in CI, even though the diff --git a/script/bootstrap b/script/bootstrap index cd4ca97..926f4c5 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -2,9 +2,10 @@ # script/bootstrap: install all dependencies needed to build and develop # this Hugo site, idempotently. Base tooling comes from nix, apt, brew, # or apk (detected in that order); assumes NOTHING is present (not git, -# make, hugo, or node). Installs hugo (the site build) and node/npm -# (prettier, used to format the repo's own markdown docs). Every install -# is guarded by a check so already-installed tools are skipped. +# make, go, hugo, or node). Installs hugo (the site build, at the exact +# version pinned below) and node/npm (prettier, used to format the +# repo's own markdown docs). Every install is guarded by a check so +# already-installed tools are skipped. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -12,6 +13,50 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" PKGMGR="" SUDO="" +# --- Hugo ------------------------------------------------------------- +# +# Hugo produces the published artifact, so its version is a property of +# the site's output, not of the build environment. It is therefore +# pinned here rather than taken from whatever the distro serves: before +# this, alpine 3.21's apk supplied hugo 0.139.0 -- a version chosen by +# nobody, roughly two years behind upstream, and liable to change +# silently whenever the base image digest moves. +# +# `go install` is the hash-verified mechanism: Go checks the module +# against the sum.golang.org checksum database. That is the mechanism +# REPO_POLICIES.md already names for Go, and it needs no hand-maintained +# sha256. The other tools this script installs stay on the package +# manager, which #19 settled is fine for build-time conveniences. +# +# hugo v0.164.0, 2026-07-06 +HUGO_VERSION="v0.164.0" + +# hugo v0.164.0's go.mod requires go >= 1.26.0, and alpine 3.21's `go` +# package is 1.23.9 built with GOTOOLCHAIN=local, so a bare `go install` +# refuses to run at all. Naming the toolchain explicitly makes Go fetch +# it through the module proxy and verify it against sum.golang.org like +# any other module, so the chain stays hash-verified end to end -- and +# the Go version that compiles hugo becomes deliberate too, instead of +# being inherited from whatever the base image happens to ship. +# go1.26.5, 2026-08-09 +HUGO_GOTOOLCHAIN="go1.26.5" + +# Standard hugo, not hugo extended: CGO_ENABLED=0 is deliberate. +# Verified that this site uses nothing extended provides -- there are no +# .scss/.sass files, no resources.ToCSS, no PostCSS, and no image +# processing (.Resize/.Fill/.Fit/images.* are all absent). The CSS is +# plain and inlined by `readFile` in baseof.html. The `+extended` on the +# apk build this replaces was incidental, not a requirement, so do not +# assume a future change needs it without rechecking the above. +HUGO_CGO_ENABLED="0" + +# Where the hugo binary lands. It has to be on the default PATH of a +# *fresh* shell, not just of this script: the Dockerfile's `RUN make +# check` and deploy.yml's `script/test` step each start their own shell +# and would never see a GOPATH bin directory. Overridable so an +# unprivileged install can point somewhere writable. +HUGO_BIN_DIR="${HUGO_BIN_DIR:-/usr/local/bin}" + detect_pkgmgr() { [ -n "$PKGMGR" ] && return 0 if command -v nix-env >/dev/null 2>&1; then @@ -28,13 +73,17 @@ detect_pkgmgr() { fi if [ "$PKGMGR" = "apt" ]; then export DEBIAN_FRONTEND=noninteractive - if [ "$(id -u)" != "0" ]; then - SUDO="sudo" - fi + detect_sudo $SUDO env DEBIAN_FRONTEND=noninteractive apt-get update fi } +detect_sudo() { + if [ -z "$SUDO" ] && [ "$(id -u)" != "0" ]; then + SUDO="sudo" + fi +} + # pkg_install pkg_install() { detect_pkgmgr @@ -50,6 +99,56 @@ missing() { ! command -v "$1" >/dev/null 2>&1 } +# True when the hugo already on PATH is the pinned version. Unlike the +# other tools, mere presence is not good enough here: an older hugo has +# to be replaced, not accepted, or the pin means nothing. +hugo_pinned() { + command -v hugo >/dev/null 2>&1 || return 1 + # `hugo version` prints e.g. "hugo v0.164.0 linux/amd64 ..." for a + # `go install` build, or "hugo v0.164.0-ce2470e+extended ..." for an + # official release binary. Compare only the vX.Y.Z part: a build of + # the same version that happens to be extended renders this site + # identically (see HUGO_CGO_ENABLED above), so there is no reason to + # overwrite a developer's existing matching install. + have="$(hugo version 2>/dev/null | awk '{print $2}' | sed 's/[-+].*//')" + [ "$have" = "$HUGO_VERSION" ] +} + +install_hugo() { + detect_sudo + + # The Go toolchain is a build-time convenience like git and make, so + # it comes from the package manager; the thing that must be + # deliberate is what it builds, which HUGO_VERSION and + # HUGO_GOTOOLCHAIN pin. + if missing go; then pkg_install go golang-go go go; fi + + # Build as the invoking user into a scratch GOBIN, then place the + # binary with `install`. Running the whole `go install` under sudo + # would work but would populate root's module cache instead of the + # user's, which is needlessly slow and surprising on a workstation. + gobin="$(mktemp -d)" + CGO_ENABLED="$HUGO_CGO_ENABLED" \ + GOTOOLCHAIN="$HUGO_GOTOOLCHAIN" \ + GOBIN="$gobin" \ + go install "github.com/gohugoio/hugo@${HUGO_VERSION}" + $SUDO install -d "$HUGO_BIN_DIR" + $SUDO install -m 0755 "$gobin/hugo" "$HUGO_BIN_DIR/hugo" + rm -rf "$gobin" + + # Drop any cached PATH lookup of the hugo we just replaced, so the + # check below tests the new binary and not the old one. + hash -r 2>/dev/null || true + + # Fail loudly rather than let a later build run on a shadowing hugo + # from somewhere earlier in PATH. + if ! hugo_pinned; then + echo "bootstrap: installed $HUGO_VERSION into $HUGO_BIN_DIR but" \ + "'hugo' on PATH is still $(hugo version 2>/dev/null || echo absent)" >&2 + exit 1 + fi +} + main() { cd "$ROOT" @@ -63,8 +162,8 @@ main() { git submodule update --init --recursive fi - # Site build. - if missing hugo; then pkg_install hugo hugo hugo hugo; fi + # Site build. Pinned and hash-verified -- see the HUGO_* constants. + if ! hugo_pinned; then install_hugo; fi # node/npm provide prettier (via npx) for formatting the docs. if missing node; then pkg_install nodejs nodejs node nodejs; fi