From 8c05ec4378e960f8769cb86935dface2ceb4421a Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 17:36:35 +0200 Subject: [PATCH] Build the binary without cgo (closes #545) make build now builds with CGO_ENABLED=0, and the image builds through it. The binary's only cgo code was mattn/go-sqlite3, which gorm.io/driver/sqlite imports and webhooker never opens: every SQLite file is opened with modernc.org/sqlite and the connection handed to gorm. Without cgo, mattn's package compiles to a stub nothing calls. The Dockerfile builds the binary once. The static relink and its netgo, osusergo and sqlite_omit_load_extension tags are gone, and with them the Makefile's GO_LDFLAGS and GO_TAGS, which only the relink used. The test phase keeps cgo for -race. No README or Dockerfile sentence says the binary needs cgo any more. Model: opus-5-5 --- Dockerfile | 27 +++------------ Makefile | 17 +++------- README.md | 33 +++++++------------ internal/versionscript/version_script_test.go | 21 ++++-------- 4 files changed, 27 insertions(+), 71 deletions(-) diff --git a/Dockerfile b/Dockerfile index 51831be..3047b21 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,8 +76,6 @@ RUN --network=none node_modules/.bin/prettier --check '**/*.md' # depends on it. # # golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24 -# Using Debian-based image because mattn/go-sqlite3 (CGO) does not -# compile on Alpine musl (off64_t is a glibc type). FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint WORKDIR /src @@ -167,9 +165,7 @@ RUN --mount=type=tmpfs,target=/tmp/tests,size=512m \ # Build stage # golang:1.26.1-bookworm (Debian-based), 2026-03-17 -# Using Debian-based image because gorm.io/driver/sqlite pulls in -# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. The image -# ships git and make, which the version step below uses. +# The image ships git and make, which the version step below uses. FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder # Nothing is wanted from the lint and test phases or from the stylesheet and @@ -211,26 +207,11 @@ RUN version="$(make version VERSION="$VERSION")"; \ fi # Builds through the Makefile's build target, which runs script/assets -# (Alpine.js, extracted from its tarball in 3p/) first. +# (Alpine.js, extracted from its tarball in 3p/) first. The target builds +# with cgo off, so the binary is statically linked and runs on the Alpine +# runtime image below as it is. RUN make build VERSION="$VERSION" -# Rebuild with static linking for the Alpine runtime. The cgo binary the -# step above builds is dynamically linked against glibc, which Alpine -# (musl) does not have. -# -# A statically linked glibc still loads shared libraries at run time for -# getaddrinfo and dlopen, and Alpine has none to load, so the build tags -# keep both out of the binary: netgo and osusergo select Go's own DNS -# resolver and user lookup instead of glibc's, and -# sqlite_omit_load_extension leaves out SQLite extension loading, which -# webhooker never uses. -# -# The static flags and tags go in through GO_LDFLAGS and GO_TAGS rather -# than a -ldflags of their own: the build target composes them with the -# -X that stamps the version, so this relink cannot silently drop the -# stamp. -RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_TAGS=netgo,osusergo,sqlite_omit_load_extension GO_LDFLAGS='-extldflags "-static"' - # Runtime stage # alpine:3.21, 2026-03-17 FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 diff --git a/Makefile b/Makefile index b2f2f36..e52a12b 100644 --- a/Makefile +++ b/Makefile @@ -16,17 +16,6 @@ VERSION ?= $(shell script/version) # exists to correct. override VERSION := $(or $(strip $(VERSION)),$(shell script/version)) -# Extra linker flags and build tags for the build target. The static -# relink in the Dockerfile passes -extldflags and its build tags through -# these rather than a -ldflags of its own, so composing flags cannot drop -# the version stamp. -# -# The build target itself always passes -trimpath and -s -w, as the Go -# Dockerfile in REPO_POLICIES.md does: no build paths, symbol table or -# debug information in the binary. -GO_LDFLAGS ?= -GO_TAGS ?= - bootstrap: @script/bootstrap @@ -54,8 +43,12 @@ fmt-check: check: @script/check +# Built with cgo off, -trimpath and -s -w, as the Go Dockerfile in +# REPO_POLICIES.md does: the binary carries no build paths, symbol table or +# debug information, and on Linux, the image's included, it is statically +# linked. build: assets - go build -trimpath -tags '$(GO_TAGS)' -ldflags '$(strip -s -w -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker + CGO_ENABLED=0 go build -trimpath -ldflags '-s -w -X main.version=$(VERSION)' -o bin/webhooker ./cmd/webhooker run: build ./bin/webhooker diff --git a/README.md b/README.md index 0a3894b..05bc2d2 100644 --- a/README.md +++ b/README.md @@ -1393,10 +1393,10 @@ It uses: management - **[go-chi](https://github.com/go-chi/chi)** for HTTP routing - **[GORM](https://gorm.io)** for database access with - **[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite)** as the runtime - SQLite driver. Note: `gorm.io/driver/sqlite` transitively depends on - `mattn/go-sqlite3`, which requires CGO at build time (see [Docker](#docker) - section) + **[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite)**, which is pure + Go, as the SQLite driver. `gorm.io/driver/sqlite` also imports + `mattn/go-sqlite3`, whose stub for builds without cgo is linked into the + binary but never used - **[slog](https://pkg.go.dev/log/slog)** (stdlib) for structured logging with TTY detection (text for dev, JSON for prod) - **[gorilla/sessions](https://github.com/gorilla/sessions)** for encrypted @@ -2135,9 +2135,7 @@ endpoint. That is the JSON key; the error text for a missing one reads `webhook_url is required`, which is the message, not the key. The database uses the -[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver at runtime, -though CGO is required at build time due to the transitive `mattn/go-sqlite3` -dependency from `gorm.io/driver/sqlite`. +[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver. #### Schema @@ -3468,16 +3466,12 @@ fixed independently of the compiler's: a step's log, and then fails. `make test` builds this stage alone. 6. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint and test phases and the `css-check` and `markdown-check` stages passing (it copies a - file from each), runs `make build` (which extracts Alpine.js from `3p/` - first), and then rebuilds the binary with `CGO_ENABLED=1` and static linking - so it runs on musl, with the build tags `netgo`, `osusergo` and - `sqlite_omit_load_extension` so it needs none of glibc's shared libraries at - run time. Both builds go through `make build`, the relink adding its - `-extldflags` via `GO_LDFLAGS` and its tags via `GO_TAGS`, so neither can - drop the `-X` that stamps the version. The version is the `VERSION` build arg - if one is given, otherwise derived from the `.git` in the context, and the - stage fails if a context with `.git` would stamp an empty version, `dev` or - `unknown` (see [Version stamping](#version-stamping)). + file from each), and runs `make build` (which extracts Alpine.js from `3p/` + first). `make build` compiles with cgo off, so the binary is statically + linked and runs on the Alpine runtime image. The version is the `VERSION` + build arg if one is given, otherwise derived from the `.git` in the context, + and the stage fails if a context with `.git` would stamp an empty version, + `dev` or `unknown` (see [Version stamping](#version-stamping)). 7. **Runtime stage** (`alpine:3.21`) — copies the static binary and `deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for all SQLite databases, exposes port 8080, and includes a health check against @@ -3489,11 +3483,6 @@ The lint and test phases invoke `gofmt`, `golangci-lint` and `go test` directly rather than `make fmt-check`, `make lint` and `make test`: those targets build docker stages, which would need a docker daemon inside this build. -The lint phase and the Go stages use Debian rather than Alpine because -`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO and does -not compile against musl. Only the final binary is statically linked, which is -what lets it run on the Alpine runtime image. - `script/cibuild` is the CI gate: it runs `script/bootstrap`, then `script/check`, then builds the image, whose build runs the lint and test phases and the stylesheet and Markdown checks again. A build that succeeds is a repo diff --git a/internal/versionscript/version_script_test.go b/internal/versionscript/version_script_test.go index 68841c5..4ec95d5 100644 --- a/internal/versionscript/version_script_test.go +++ b/internal/versionscript/version_script_test.go @@ -153,19 +153,15 @@ func TestVersion_IsStableAcrossInvocations(t *testing.T) { require.Equal(t, first, second) } -// The build target is the only place composing linker flags. If a -// future edit drops either half, the binary silently reports "dev" -// again (the defect this package exists for) or fails to link on -// Alpine. If it stops passing GO_TAGS, the image's static binary links -// glibc's getaddrinfo and dlopen again. -func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) { +// The build target is the only place that stamps the version. If a +// future edit drops the -X flag, the binary silently reports "dev" +// again, the defect this package exists for. +func TestMakefile_BuildStampsTheVersion(t *testing.T) { t.Parallel() makefile := read(t, makefilePath) require.Contains(t, makefile, "-X main.version=$(VERSION)") - require.Contains(t, makefile, "$(GO_LDFLAGS)") - require.Contains(t, makefile, "-tags '$(GO_TAGS)'") require.Contains(t, makefile, "VERSION ?= $(shell script/version)") } @@ -224,8 +220,8 @@ func requireMake(t *testing.T) { } } -// Every compile in the image goes through the build target, so the -// static relink cannot replace the flags that carry the stamp. +// The image compiles through the build target, so it carries the +// target's version stamp. func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) { t.Parallel() @@ -234,10 +230,7 @@ func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) { require.NotContains(t, dockerfile, "go build", "a raw go build bypasses the Makefile's -X flag") require.Contains(t, dockerfile, "ARG VERSION") - require.Contains(t, dockerfile, - `make build VERSION="$VERSION"`+ - ` GO_TAGS=netgo,osusergo,sqlite_omit_load_extension`+ - ` GO_LDFLAGS='-extldflags "-static"'`) + require.Contains(t, dockerfile, `RUN make build VERSION="$VERSION"`) } func read(t *testing.T, path string) string {