From 1e7b0c3b5acbde230c7bba5d8abda29d149bdd9a Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 21 Sep 2026 07:45:47 +0000 Subject: [PATCH] Hash-verify the Go toolchain in the release workflow (closes #105) The release workflow installed Go via actions/setup-go, which pins the action but not the toolchain tarball it downloads at runtime -- the compiler that produces the published binaries was the last external input in the release path verified against nothing in the repo, against REPO_POLICIES.md's hash-pin rule. New script/install-go, modelled on script/install-goreleaser, downloads the exact go.dev archive for go.mod's `go` directive and refuses it unless its sha256 matches a value committed in the script. release.yml calls it instead of setup-go and sets GOTOOLCHAIN=local so that exact compiler builds the release. The version is not duplicated: go.mod owns it and install-go fails when its committed GO_VERSION disagrees, so bumping Go edits go.mod, the checksum, and the Dockerfile golang digest together. Model: opus-4-8 --- .gitea/workflows/release.yml | 43 ++++------ README.md | 8 ++ TODO.md | 10 +++ script/install-go | 161 +++++++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 25 deletions(-) create mode 100755 script/install-go diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index d2578ea..b083588 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -20,33 +20,21 @@ jobs: # check.yml runs script/cibuild, which does all of its work inside # the digest-pinned Dockerfile images -- so without this step the # release either fails at the before-hook or, worse, ships binaries - # built by whatever unpinned Go the runner happens to carry. - # REPO_POLICIES.md requires every external reference to be pinned, - # and script/release already refuses a goreleaser that is not the - # pinned build; the compiler that actually produces the artifacts - # is the last thing that should be exempt from that. + # built by whatever Go the runner happens to carry. # - # go-version-file rather than a literal: go.mod's `go 1.26.1` is - # the single source of truth for the toolchain, the same way the - # Dockerfile FROM line is the single source of truth for the - # linter version that script/lint enforces. It is a three-component - # version, so setup-go resolves it exactly -- no silent drift onto - # a newer patch release. - # - # actions/setup-go v5.6.0, 2025-12-15. Pinned by commit sha, like - # the checkout above. v5.x is a node20 action, matching the node20 - # actions/checkout v4 already in use here; the v6/v7 line requires - # a node24 runner, which this Gitea runner has never been asked - # for and cannot be assumed to provide. + # actions/setup-go would pin the action by commit sha, but the Go + # tarball it downloads at runtime is verified against no value in + # this repo, and the action exposes no checksum input. + # REPO_POLICIES.md requires every external reference to be pinned + # by hash with no exceptions, and this is the compiler that + # produces the published binaries -- the input where a substituted + # artifact matters most. So Go is installed the way goreleaser is: + # script/install-go downloads the exact archive for go.mod's `go` + # directive and refuses it unless its sha256 matches the value + # committed in the script, then puts .tool/go/bin on PATH for the + # steps below. - name: Install Go - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff - with: - go-version-file: go.mod - # setup-go's module cache needs a runner-side cache backend. - # A release is cut rarely and a cold module download costs - # seconds; a release failing because a cache service is absent - # costs a re-tag. Off, deliberately. - cache: false + run: script/install-go - name: Install goreleaser run: script/install-goreleaser - name: Release @@ -58,3 +46,8 @@ jobs: # It is deliberately not the runner's automatic token, which is # not guaranteed to carry that scope. GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} + # Build with the toolchain install-go just verified, never a + # different one auto-downloaded from a `toolchain` directive: + # the point of the hash pin is that this exact compiler makes + # the release. + GOTOOLCHAIN: local diff --git a/README.md b/README.md index ccd0220..21917c3 100644 --- a/README.md +++ b/README.md @@ -659,6 +659,14 @@ them. We provide: called by `script/bootstrap`; the release workflow calls it directly because it needs `goreleaser` but not the Docker daemon `script/bootstrap` insists on. +* `script/install-go` — install the Go toolchain named by `go.mod`'s + `go` directive into `.tool/go` from a sha256-verified `go.dev` + archive, and put it on `PATH`. Idempotent. Called only by the release + workflow, which needs a host Go for `goreleaser` to shell out to; + nothing else on the release runner does. `actions/setup-go` is not + used because it verifies the downloaded toolchain against no value in + this repo. Bumping Go edits `go.mod`, the checksum in this script, and + the `Dockerfile` `golang` digest together. * `script/release` — cross-compile and publish the release artifacts with the pinned `goreleaser`. Refuses a `goreleaser` on `PATH` whose version is not the pinned one, on the same reasoning as `script/lint`. diff --git a/TODO.md b/TODO.md index 3f2ef0d..f3bc51d 100644 --- a/TODO.md +++ b/TODO.md @@ -31,6 +31,16 @@ release" is exactly the contradiction local `make check` ([issue #122](https://git.eeqj.de/sneak/vaultik/issues/122)). +- 2026-09-21: Hash-verified the Go toolchain in the release workflow + ([issue #105](https://git.eeqj.de/sneak/vaultik/issues/105)). New + `script/install-go` downloads the exact `go.dev` archive for `go.mod`'s + `go` directive and refuses it unless its sha256 matches a value + committed in the script; `.gitea/workflows/release.yml` calls it + instead of `actions/setup-go`, which verified the downloaded toolchain + against nothing in the repo. `GOTOOLCHAIN: local` on the release step + keeps that exact compiler from auto-switching. Bumping Go now touches + `go.mod`, the checksum, and the `Dockerfile` `golang` digest together. + - 2026-08-10: Moved every lint run into its own container, as a build step ([issue #113](https://git.eeqj.de/sneak/vaultik/issues/113)). New root `Dockerfile.lint`, built by `script/lint`, runs diff --git a/script/install-go b/script/install-go new file mode 100755 index 0000000..38056dc --- /dev/null +++ b/script/install-go @@ -0,0 +1,161 @@ +#!/bin/sh +# script/install-go: install the Go toolchain pinned by go.mod into the +# repo-local tool directory, verified against a committed sha256. Our +# own extension to scripts-to-rule-them-all. Idempotent: exits at once +# when the pinned toolchain is already installed. +# +# Only .gitea/workflows/release.yml calls this. goreleaser is not a +# compiler: it shells out to `go` for the `before:` hook and for every +# one of the four cross-compiles, so the release runner needs a Go +# toolchain on PATH. check.yml never does -- it builds inside the +# digest-pinned Dockerfile images -- so this is the release path's only +# host Go, and per REPO_POLICIES.md it must be pinned by hash. +# actions/setup-go exposes no checksum input, so Go is installed the way +# script/install-goreleaser installs goreleaser: download the exact +# archive from go.dev and refuse it unless its sha256 matches the value +# committed below. +# +# The version is go.mod's `go` directive, the single source of truth for +# the toolchain. GO_VERSION below MUST equal it, and this script fails +# when they disagree -- so bumping Go is one reviewed change touching +# go.mod, the checksum here, and the Dockerfile golang digest together. +# +# Linux only, because that is what the release runner is. A darwin dev +# building a snapshot uses their own Go; supporting an OS means adding +# its checksums. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +# Go 1.26.1, 2026-09-21. Checksums are the sha256 values go.dev publishes +# for each archive at https://go.dev/dl/ (also in its ?mode=json +# manifest). +GO_VERSION="1.26.1" +SHA256_LINUX_AMD64="031f088e5d955bab8657ede27ad4e3bc5b7c1ba281f05f245bcc304f327c987a" +SHA256_LINUX_ARM64="a290581cfe4fe28ddd737dde3095f3dbeb7f2e4065cab4eae44dfc53b760c2f7" + +GOROOT_DIR="$ROOT/.tool/go" +GOCMD="$GOROOT_DIR/bin/go" + +# The `go` directive in go.mod, e.g. "1.26.1" from `go 1.26.1`. +gomod_go_version() { + sed -n 's/^go \([0-9][0-9.]*\).*/\1/p' "$ROOT/go.mod" | head -n 1 +} + +# Print the version of the go at $1 as "1.26.1", or nothing if it is not +# usable. `go version` prints "go version go1.26.1 linux/amd64". +go_version() { + [ -x "$1" ] || return 0 + "$1" version 2>/dev/null | + sed -n 's/^go version go\([0-9][0-9.]*\) .*/\1/p' | + head -n 1 +} + +verify_sha256() { + file="$1" + want="$2" + if command -v sha256sum >/dev/null 2>&1; then + got="$(sha256sum "$file" | cut -d' ' -f1)" + elif command -v shasum >/dev/null 2>&1; then + got="$(shasum -a 256 "$file" | cut -d' ' -f1)" + else + echo "install-go: no sha256sum or shasum available" >&2 + return 1 + fi + if [ "$got" != "$want" ]; then + echo "install-go: checksum mismatch for $file" >&2 + echo " expected: $want" >&2 + echo " actual: $got" >&2 + return 1 + fi +} + +# On a Gitea/GitHub Actions runner, put the toolchain on PATH for the +# steps that follow by appending to the file named by $GITHUB_PATH. A +# no-op off CI, where the caller manages its own PATH. +export_ci_path() { + [ -n "${GITHUB_PATH:-}" ] || return 0 + echo "$GOROOT_DIR/bin" >>"$GITHUB_PATH" +} + +main() { + cd "$ROOT" + + want="$(gomod_go_version)" + if [ "$want" != "$GO_VERSION" ]; then + echo "install-go: go.mod says go $want but this script pins" \ + "$GO_VERSION." >&2 + echo " Update GO_VERSION and the checksums in this script to" \ + "match go.mod." >&2 + exit 1 + fi + + # Already installed from a previous run? Then just fix PATH and stop. + if [ "$(go_version "$GOCMD")" = "$GO_VERSION" ]; then + echo "go $GO_VERSION already installed in .tool/go" + export_ci_path + return 0 + fi + + os="$(uname -s)" + arch="$(uname -m)" + case "$os" in + Linux) os="linux" ;; + *) + echo "install-go: unsupported OS $os (release runner is Linux)" >&2 + exit 1 + ;; + esac + case "$arch" in + x86_64 | amd64) + arch="amd64" + sum="$SHA256_LINUX_AMD64" + ;; + arm64 | aarch64) + arch="arm64" + sum="$SHA256_LINUX_ARM64" + ;; + *) + echo "install-go: no pinned checksum for architecture $arch" >&2 + exit 1 + ;; + esac + + archive="go${GO_VERSION}.${os}-${arch}.tar.gz" + url="https://go.dev/dl/${archive}" + + if ! command -v curl >/dev/null 2>&1; then + echo "install-go: curl is required" >&2 + exit 1 + fi + + dl="$(mktemp -d)" + mkdir -p "$ROOT/.tool" + stage="$(mktemp -d "$ROOT/.tool/.go-install.XXXXXX")" + # shellcheck disable=SC2064 # expand the paths now, not at trap time + trap "rm -rf '$dl' '$stage'" EXIT INT TERM + + echo "installing go $GO_VERSION for ${os}-${arch}" + curl -fsSL --retry 3 -o "$dl/$archive" "$url" + verify_sha256 "$dl/$archive" "$sum" + + # The archive unpacks to a top-level `go/` directory. Extract it into + # a staging directory on the same filesystem as the destination, then + # rename it into place so a concurrent run never observes a + # half-written toolchain. + tar -xzf "$dl/$archive" -C "$stage" + rm -rf "$GOROOT_DIR" + mv "$stage/go" "$GOROOT_DIR" + + installed="$(go_version "$GOCMD")" + if [ "$installed" != "$GO_VERSION" ]; then + echo "install-go: installed toolchain reports '$installed'," \ + "expected '$GO_VERSION'" >&2 + exit 1 + fi + + echo "go $GO_VERSION installed to .tool/go" + export_ci_path +} + +main "$@"