Author SHA1 Message Date
clawbot d809990832 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
2026-09-21 07:45:47 +00:00
8 changed files with 230 additions and 219 deletions
+2 -2
View File
@@ -1,9 +1,9 @@
name: check
on:
push:
branches: [main, next]
branches: [main]
pull_request:
branches: [main, next]
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
+18 -25
View File
@@ -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
+5 -5
View File
@@ -72,11 +72,11 @@ RUN [ -n "$CHECK_EPOCH" ] || exit 1
# running, and exits 0 reporting `0 issues.` on a tree the real config
# fails. Demonstrated on this repo at this pin, recorded on
# https://git.eeqj.de/sneak/vaultik/pulls/114: with a planted
# over-length line, `script/lint` exits 1 naming the `revive` finding
# with `linters:` and exits 0 with `linterz:`. A set-but-ineffective
# config quietly falling back to defaults is precisely the false-green
# class this gate exists to eliminate, so it must not sit in the gate's
# own configuration.
# over-length line, `script/lint` exits 1 naming the `lll` finding with
# `linters:` and exits 0 with `linterz:`. A set-but-ineffective config
# quietly falling back to defaults is precisely the false-green class
# this gate exists to eliminate, so it must not sit in the gate's own
# configuration.
#
# `config verify` catches it, and it does so OFFLINE at this pinned
# version -- verified, not assumed. Under `docker run --network none`
+8 -2
View File
@@ -649,6 +649,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`.
@@ -716,8 +724,6 @@ them. We provide:
then the product image). Either failing fails the script. It runs the
checks in the same containers CI does, from a clean copy of the tree,
so it also catches anything that depends on host state.
`.gitea/workflows/check.yml` runs it on every push to `main` and
`next` and on every pull request against either.
It passes a fresh `--build-arg CHECK_EPOCH` to each build, unique per
invocation, which both files declare immediately above their check
+9 -5
View File
@@ -25,11 +25,15 @@ release" is exactly the contradiction
# Completed Steps
- 2026-09-21: Made `.gitea/workflows/check.yml` run on pushes to `main`
and `next` and on pull requests against either, so unit PRs (whose
base is `next`) and `next` itself get a CI run instead of relying on a
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)).
+27 -179
View File
@@ -1,8 +1,6 @@
package main_test
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
@@ -254,64 +252,29 @@ func TestNoHostLintPathRemains(t *testing.T) {
}
name := filepath.Join("script", entry.Name())
lines, err := shellCode(readRepoFile(t, name))
require.NoError(t, err, "scanning %s", name)
for _, line := range lines {
for _, line := range shellCode(readRepoFile(t, name)) {
assertLinterIsContainerised(t, name, line)
}
}
}
// assertLinterIsContainerised fails unless every command that names the
// linter on this joined line is a docker command. Merely mentioning
// docker somewhere on the line is not enough; see linterRunsInDocker.
// assertLinterIsContainerised fails if the line runs the linter without
// handing it to docker first. Position matters: docker has to come
// before the binary, or the line is running the host linter and merely
// mentioning docker afterwards.
func assertLinterIsContainerised(t *testing.T, name, line string) {
t.Helper()
assert.True(t, linterRunsInDocker(line),
"%s runs %s outside a container; every command that names the"+
" linter must begin with docker (line: %s)", name, linterBinary,
line)
}
// linterRunsInDocker reports whether the linter, wherever it appears on
// this joined shell line, is only ever the argument of a docker command.
// The line is cut into the simple commands the shell would run -- on
// `;`, `&&`, `||` and `|` -- and every command that names the linter
// must begin with `docker`. This is what distinguishes the one
// legitimate invocation, script/lint-fix's `docker run ... golangci-lint
// run ...`, from evasions like `docker info; golangci-lint run` or
// `docker info || golangci-lint run`, where the linter sits in a command
// of its own that docker does not introduce.
func linterRunsInDocker(line string) bool {
for _, command := range splitShellCommands(line) {
if !strings.Contains(command, linterBinary) {
continue
}
if !strings.HasPrefix(strings.TrimSpace(command), "docker") {
return false
}
at := strings.Index(line, linterBinary)
if at < 0 {
return
}
return true
}
docker := strings.Index(line, "docker")
// splitShellCommands breaks a joined shell line into the separate simple
// commands the shell would run, cutting at the `;`, `&&`, `||` and `|`
// operators (`||` before `|`, so the two-character operator is not split
// twice). It is deliberately blind to quoting and to `$(...)`: no line
// under guard puts one of these operators inside a string, and a scan
// that tried to account for that would be the kind of half-parser this
// file avoids.
func splitShellCommands(line string) []string {
for _, op := range []string{"&&", "||", "|", ";"} {
line = strings.ReplaceAll(line, op, "\n")
}
return strings.Split(line, "\n")
assert.True(t, docker >= 0 && docker < at,
"%s runs %s on the host; every lint run happens in a container"+
" (line: %s)", name, linterBinary, line)
}
// TestShellCodeSeesCodeAndNotProse keeps the scanner above honest. It
@@ -324,70 +287,20 @@ func splitShellCommands(line string) []string {
func TestShellCodeSeesCodeAndNotProse(t *testing.T) {
t.Parallel()
// A `<<` inside quotes is not a here-document, so the code after it
// is still scanned; a real `<<EOF` opens one and its body is dropped.
script := strings.Join([]string{
"#!/bin/sh",
"# a comment naming golangci-lint",
"cat >&2 <<EOF",
"prose naming golangci-lint, printed not executed",
"EOF",
`echo "a left shift << is not a here-document"`,
"docker run --rm \\",
" \"$image\" \\",
" golangci-lint run ./...",
}, "\n")
lines, err := shellCode(script)
require.NoError(t, err)
assert.Equal(t,
[]string{
"cat >&2 <<EOF",
`echo "a left shift << is not a here-document"`,
`docker run --rm "$image" golangci-lint run ./...`,
},
lines)
// A here-document still open at end of file must be a loud error,
// not a silent truncation of everything the scanner has yet to see.
unterminated := strings.Join([]string{
"cat <<EOF",
"body line naming golangci-lint, no terminator follows",
}, "\n")
_, err = shellCode(unterminated)
require.Error(t, err)
}
// TestLinterCommandMustBeginWithDocker pins the property that a mention
// of docker somewhere on the line is not enough: the command that
// actually runs the linter has to be a docker command. The two evasions
// from the issue place the linter in a command of its own, joined to a
// harmless docker command by `;` or `||`; both must be rejected. The
// containerised invocation script/lint-fix writes -- docker run with the
// linter as its argument -- must still be accepted.
func TestLinterCommandMustBeginWithDocker(t *testing.T) {
t.Parallel()
rejected := []string{
"docker info >/dev/null; golangci-lint run ./...",
"docker info || golangci-lint run ./...",
"docker build . && golangci-lint run ./... | tee log",
}
for _, line := range rejected {
assert.False(t, linterRunsInDocker(line),
"a linter command docker does not introduce must be rejected: %s",
line)
}
accepted := []string{
`docker run --rm "$image" golangci-lint run ./...`,
`docker run --rm --user x --volume "$ROOT:/src" img golangci-lint run --fix ./...`,
}
for _, line := range accepted {
assert.True(t, linterRunsInDocker(line),
"a docker-introduced linter command must be accepted: %s", line)
}
[]string{"cat >&2 <<EOF", `docker run --rm "$image" golangci-lint run ./...`},
shellCode(script))
}
// assertEpochExpandedInto fails unless some instruction runs the named
@@ -497,9 +410,7 @@ func indexContaining(found []string, want string) int {
// shellCode returns a POSIX shell script's executable lines: comments
// dropped, here-document bodies dropped, and backslash continuations
// joined so a multi-line command is a single string. Whitespace is
// collapsed, as it is for Dockerfile instructions. A here-document left
// open at end of file is an error rather than a silent truncation of
// everything after its opener.
// collapsed, as it is for Dockerfile instructions.
//
// Both exclusions are load-bearing rather than tidiness. The scripts
// name golangci-lint in prose to state that the host binary is never
@@ -507,11 +418,7 @@ func indexContaining(found []string, want string) int {
// container invocation -- script/lint-fix's `docker run`, whose linter
// command sits several lines below the word `docker` -- be recognised
// as containerised.
//
// This is a text scan, not a shell: it cannot see a linter name
// assembled at runtime, one split across a continuation, a script in a
// subdirectory of script/, or anything in the Makefile.
func shellCode(contents string) ([]string, error) {
func shellCode(contents string) []string {
var (
out []string
joined string
@@ -545,82 +452,23 @@ func shellCode(contents string) ([]string, error) {
joined = ""
}
if terminate != "" {
return nil, fmt.Errorf("%w: terminator %q", errUnterminatedHeredoc,
terminate)
}
return out, nil
return out
}
// errUnterminatedHeredoc is what shellCode returns when a here-document
// is still open at end of file. Its callers require its absence, so an
// unterminated body -- which would otherwise be swallowed silently --
// fails the guard loudly.
var errUnterminatedHeredoc = errors.New(
"here-document opened but never closed before end of file")
// heredocTerminator returns the delimiter word of the here-document the
// command opens, or "" if it opens none. A `<<` only opens one when it
// is a real redirection: outside single and double quotes, and followed
// by a delimiter word. A `<<` inside a quoted string, or an arithmetic
// left shift like `$((x << 2))`, is not a here-document; the former is
// the case this guards, the latter appears in no script here. Only the
// first opener on a line is recognised; nothing in script/ opens two.
// heredocTerminator returns the terminator of the here-document a
// command opens, or "" if it opens none. Only the first on a line is
// recognised; nothing in script/ opens two.
func heredocTerminator(line string) string {
var quote byte // 0 when outside quotes, else '\'' or '"'
for i := 0; i+1 < len(line); i++ {
c := line[i]
switch {
case quote != 0:
if c == quote {
quote = 0
}
case c == '\'' || c == '"':
quote = c
case c == '<' && line[i+1] == '<':
return heredocWord(line[i+2:])
}
_, after, opens := strings.Cut(line, "<<")
if !opens {
return ""
}
return ""
}
// `<<-` strips leading tabs from the body; the terminator word is
// the same either way, and callers compare against trimmed lines.
word, _, _ := strings.Cut(strings.TrimPrefix(after, "-"), " ")
// heredocWord extracts the delimiter that follows `<<` or `<<-`: it drops
// an optional `-`, skips blanks, then reads the delimiter -- quoted or
// bare -- and returns it with quotes removed. `<<-'EOF'` and `<< EOF`
// both yield "EOF". It returns "" when no word follows, so a bare `<<`
// opens nothing.
func heredocWord(after string) string {
after = strings.TrimLeft(strings.TrimPrefix(after, "-"), " \t")
var (
word strings.Builder
quote byte
)
for i := range len(after) {
c := after[i]
switch {
case quote != 0:
if c == quote {
quote = 0
} else {
word.WriteByte(c)
}
case c == '\'' || c == '"':
quote = c
case c == ' ' || c == '\t':
return word.String()
default:
word.WriteByte(c)
}
}
return word.String()
return strings.Trim(word, `'"`)
}
// readRepoFile reads a file by its path relative to the repository
+1 -1
View File
@@ -135,7 +135,7 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
}
cmd.Flags().BoolVar(&opts.Cron, "cron", false,
"Run in cron mode (silent unless warning or error)")
"Run in cron mode (silent unless error)")
cmd.Flags().BoolVar(&opts.Prune, "prune", false,
"After backup, drop older snapshots of the same name and remove "+
"orphaned blobs")
+160
View File
@@ -0,0 +1,160 @@
#!/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. 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 "$@"