Build the binary without cgo (closes #545)
check / check (push) Waiting to run

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
This commit is contained in:
2026-10-15 14:18:46 +00:00
parent dfe8f3138f
commit a33af69416
4 changed files with 26 additions and 71 deletions
+4 -23
View File
@@ -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
+4 -12
View File
@@ -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,11 @@ fmt-check:
check:
@script/check
# Built with cgo off, -trimpath and -s -w, as the Go Dockerfile in
# REPO_POLICIES.md does: the binary is statically linked and carries no
# build paths, symbol table or debug information.
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
+11 -22
View File
@@ -1388,10 +1388,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
@@ -2130,9 +2130,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
@@ -3456,16 +3454,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
@@ -3477,11 +3471,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
+7 -14
View File
@@ -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 {