Hash-verify the Go toolchain in the release workflow (closes #105)
The release workflow installed Go with actions/setup-go, which pins the action but not the Go archive it downloads, so the compiler that builds the published binaries was verified against nothing in this repo. New script/install-go, modelled on script/install-goreleaser, downloads the go.dev archive for the version in go.mod and refuses it unless its sha256 matches the value committed in the script. It fails if its version disagrees with go.mod, and on any OS or architecture other than the Linux release runners. GOTOOLCHAIN=local on the release step keeps the verified toolchain from switching itself. Judgement call: release path only; script/bootstrap still uses the host Go. model: claude-opus-4-8 (implementation, review); claude-fable-5-1 (merge)
This commit was merged in pull request #132.
This commit is contained in:
@@ -20,33 +20,21 @@ jobs:
|
|||||||
# check.yml runs script/cibuild, which does all of its work inside
|
# check.yml runs script/cibuild, which does all of its work inside
|
||||||
# the digest-pinned Dockerfile images -- so without this step the
|
# the digest-pinned Dockerfile images -- so without this step the
|
||||||
# release either fails at the before-hook or, worse, ships binaries
|
# release either fails at the before-hook or, worse, ships binaries
|
||||||
# built by whatever unpinned Go the runner happens to carry.
|
# built by whatever 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.
|
|
||||||
#
|
#
|
||||||
# go-version-file rather than a literal: go.mod's `go 1.26.1` is
|
# actions/setup-go would pin the action by commit sha, but the Go
|
||||||
# the single source of truth for the toolchain, the same way the
|
# tarball it downloads at runtime is verified against no value in
|
||||||
# Dockerfile FROM line is the single source of truth for the
|
# this repo, and the action exposes no checksum input.
|
||||||
# linter version that script/lint enforces. It is a three-component
|
# REPO_POLICIES.md requires every external reference to be pinned
|
||||||
# version, so setup-go resolves it exactly -- no silent drift onto
|
# by hash with no exceptions, and this is the compiler that
|
||||||
# a newer patch release.
|
# produces the published binaries -- the input where a substituted
|
||||||
#
|
# artifact matters most. So Go is installed the way goreleaser is:
|
||||||
# actions/setup-go v5.6.0, 2025-12-15. Pinned by commit sha, like
|
# script/install-go downloads the exact archive for go.mod's `go`
|
||||||
# the checkout above. v5.x is a node20 action, matching the node20
|
# directive and refuses it unless its sha256 matches the value
|
||||||
# actions/checkout v4 already in use here; the v6/v7 line requires
|
# committed in the script, then puts .tool/go/bin on PATH for the
|
||||||
# a node24 runner, which this Gitea runner has never been asked
|
# steps below.
|
||||||
# for and cannot be assumed to provide.
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
run: script/install-go
|
||||||
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
|
|
||||||
- name: Install goreleaser
|
- name: Install goreleaser
|
||||||
run: script/install-goreleaser
|
run: script/install-goreleaser
|
||||||
- name: Release
|
- name: Release
|
||||||
@@ -58,3 +46,8 @@ jobs:
|
|||||||
# It is deliberately not the runner's automatic token, which is
|
# It is deliberately not the runner's automatic token, which is
|
||||||
# not guaranteed to carry that scope.
|
# not guaranteed to carry that scope.
|
||||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
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
|
||||||
|
|||||||
@@ -658,6 +658,14 @@ them. We provide:
|
|||||||
called by `script/bootstrap`; the release workflow calls it directly
|
called by `script/bootstrap`; the release workflow calls it directly
|
||||||
because it needs `goreleaser` but not the Docker daemon
|
because it needs `goreleaser` but not the Docker daemon
|
||||||
`script/bootstrap` insists on.
|
`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
|
* `script/release` — cross-compile and publish the release artifacts
|
||||||
with the pinned `goreleaser`. Refuses a `goreleaser` on `PATH` whose
|
with the pinned `goreleaser`. Refuses a `goreleaser` on `PATH` whose
|
||||||
version is not the pinned one, on the same reasoning as `script/lint`.
|
version is not the pinned one, on the same reasoning as `script/lint`.
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ release" is exactly the contradiction
|
|||||||
local `make check`
|
local `make check`
|
||||||
([issue #122](https://git.eeqj.de/sneak/vaultik/issues/122)).
|
([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
|
- 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)).
|
step ([issue #113](https://git.eeqj.de/sneak/vaultik/issues/113)).
|
||||||
New root `Dockerfile.lint`, built by `script/lint`, runs
|
New root `Dockerfile.lint`, built by `script/lint`, runs
|
||||||
|
|||||||
Executable
+161
@@ -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 "$@"
|
||||||
Reference in New Issue
Block a user