diff --git a/Dockerfile b/Dockerfile index 207b733..cfecdf6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,14 +61,28 @@ RUN script/fetch-assets # Run tests and build RUN make test -RUN make build + +# Version stamped into the binary. .dockerignore excludes .git/, so +# nothing in this stage can derive it: script/docker resolves it on the +# host and passes it in. The default is what a bare `docker build .` +# with no --build-arg gets, and it names no tag the tree may not be at. +# +# Declared here, below the test and asset steps, so a changed version +# does not invalidate their cached layers. +ARG VERSION=unknown + +RUN make build VERSION="$VERSION" # Rebuild with static linking for Alpine runtime. # make build already verified compilation. # The CGO binary from `make build` is dynamically linked against glibc, # which doesn't exist on Alpine (musl). Rebuild with static linking so # the binary runs on Alpine without glibc. -RUN CGO_ENABLED=1 go build -ldflags '-extldflags "-static"' -o bin/webhooker ./cmd/webhooker +# +# The static flags go in through GO_LDFLAGS 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_LDFLAGS='-extldflags "-static"' # Runtime stage # alpine:3.21, 2026-03-17 diff --git a/Makefile b/Makefile index 58935b2..b594829 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,18 @@ -.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css +.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css version # Default target .DEFAULT_GOAL := check +# Version stamped into the binary. Derived from git by script/version; +# override it (`make build VERSION=v1.2.3`) where git metadata is +# unavailable, which is how the Dockerfile passes its build arg in. +VERSION ?= $(shell script/version) + +# Extra linker flags for the build target. The static relink in the +# Dockerfile adds -extldflags here rather than passing its own -ldflags, +# so composing flags cannot drop the version stamp. +GO_LDFLAGS ?= + bootstrap: @script/bootstrap @@ -28,7 +38,7 @@ check: @script/check build: - go build -o bin/webhooker ./cmd/webhooker + go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker run: build ./bin/webhooker @@ -40,6 +50,9 @@ deps: go mod download go mod tidy +version: + @echo $(VERSION) + docker: @script/docker diff --git a/README.md b/README.md index 486233a..7d39ec9 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,8 @@ make fmt-check # Fail if gofmt would change anything (writes nothing) make lint # Run golangci-lint in Docker (Dockerfile.lint) make test # Run tests with race detection make check # test + lint + fmt-check (CI gate) -make build # Build binary to bin/webhooker +make build # Build binary to bin/webhooker (version-stamped) +make version # Print the version this checkout would stamp make run # build, then run ./bin/webhooker make dev # go run ./cmd/webhooker make deps # go mod download + go mod tidy @@ -677,6 +678,9 @@ Upgrade procedure: 3. Pull the new image and start it. 4. Confirm `database migrations completed` in the logs before putting traffic back on it. +5. Confirm the new build is the one running: + `curl -s http://host:8080/.well-known/healthcheck` reports the + version it was stamped with (see [Version stamping](#version-stamping)). **Downgrade is unsupported.** Once a newer binary has migrated the files there is no way to move them back. `AutoMigrate` is additive — it adds @@ -687,6 +691,40 @@ silent divergence, not a startup error. The only supported way back to an older version is restoring the pre-upgrade backup, which discards everything received since that backup was taken. +### Version stamping + +The binary reports its version at `/.well-known/healthcheck` (the +`version` field), in the UI footer, and in the startup log line +(`msg=starting`, `version=...`). It is also the Sentry release name, +as `webhooker-{version}`. The value is stamped in at build time by the +linker; it is not read from a file at runtime, so it identifies the +build itself. + +`script/version` produces the value and both build paths use it: + +| Build | What it reports | +| --- | --- | +| Clean checkout at a tag | exactly that tag, e.g. `v1.0.0` | +| Commits past a tag | `v1.0.0-3-g1a2b3c4` — tag, commits since, short SHA | +| No tag reachable | the short SHA, e.g. `1a2b3c4` | +| Uncommitted changes | the above with a `-dirty` suffix | +| No git metadata | `unknown` | + +`unknown` is what a source tarball or a `docker build .` with no +`--build-arg VERSION=...` reports. `.dockerignore` excludes `.git/`, so +the build context carries no git metadata and the image cannot derive +the version itself: `script/docker` (and so `make docker`) resolves it +on the host and passes it in as the `VERSION` build arg. A build that +reports `unknown` is a build nobody told what it was; it is not a +failure, but it cannot be traced back to a commit. + +`make version` prints what the current checkout would stamp, and +`make build VERSION=v1.2.3` overrides it. + +Nothing that varies between two builds of the same commit is stamped — +no timestamp, no hostname, no builder identity — so two builds of one +commit still produce a byte-identical binary. + ### Backups contain secrets Treat a backup with the same care as the credentials inside it. Encrypt @@ -829,9 +867,11 @@ anything. This repository adheres to the [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) standard: normalized scripts in `script/` are the entrypoints for the -development workflow. Ten of the Makefile's sixteen targets are thin -shims that call them; `build`, `run`, `dev`, `deps`, `clean` and `css` -are inline commands with no script behind them. We provide: +development workflow. Ten of the Makefile's seventeen targets are thin +shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and +`version` are inline commands with no script behind them, though +`build` and `version` both take their value from `script/version`. We +provide: - `script/bootstrap` — install all dependencies (idempotent) - `script/setup` — make a fresh clone ready for development @@ -844,7 +884,11 @@ are inline commands with no script behind them. We provide: - `script/fmt` — format all code (writes) - `script/fmt-check` — check formatting (read-only) - `script/check` — run test, lint, and fmt-check -- `script/docker` — build the Docker image tagged via `script/projectname` +- `script/version` — output the version to stamp into the binary (see + [Version stamping](#version-stamping)) +- `script/docker` — build the Docker image tagged via + `script/projectname`, passing `script/version`'s output in as the + `VERSION` build arg - `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile runs the checks, so a green build implies a green repo) - `script/ci-mark-superseded` — CI helper: mark the commits whose run a @@ -2379,7 +2423,7 @@ webhooker/ ├── script/ # Scripts to Rule Them All entrypoints ├── Dockerfile # Three stages: lint, test+build, Alpine runtime ├── Dockerfile.lint # Lint-only image built by script/lint -├── Makefile # 10 of 16 targets shim script/; 6 are inline +├── Makefile # 10 of 17 targets shim script/; 7 are inline ├── go.mod / go.sum └── .golangci.yml # Linter configuration ``` @@ -2679,7 +2723,11 @@ version is fixed independently of the compiler's: stage passing (it copies a file from it), runs `script/fetch-assets` to download and verify the third-party browser assets, then runs `make test` and `make build`, and finally rebuilds the binary with - `CGO_ENABLED=1` and static linking so it runs on musl. + `CGO_ENABLED=1` and static linking so it runs on musl. Both builds + go through `make build`, the relink adding its `-extldflags` via + `GO_LDFLAGS`, so neither can drop the `-X` that stamps the version. + The version arrives as the `VERSION` build arg, since the context + has no `.git` (see [Version stamping](#version-stamping)). 3. **Runtime stage** (`alpine:3.21`) — copies the static binary, creates the `/var/lib/webhooker` directory for all SQLite databases, runs as the non-root `webhooker` user (UID 1000), exposes port 8080, diff --git a/internal/handlers/footer_version_test.go b/internal/handlers/footer_version_test.go new file mode 100644 index 0000000..58f2078 --- /dev/null +++ b/internal/handlers/footer_version_test.go @@ -0,0 +1,38 @@ +package handlers_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "sneak.berlin/go/webhooker/internal/globals" + "sneak.berlin/go/webhooker/internal/handlers" + "sneak.berlin/go/webhooker/internal/session" +) + +// The footer in base.html falls back to the literal "dev" when the +// template data carries no version, which is what every page rendered +// while nothing supplied one. The operator uses the footer to tell +// which build is live, so it has to carry the stamped value. +func TestFooterReportsStampedVersion(t *testing.T) { + t.Parallel() + + var ( + h *handlers.Handlers + sess *session.Session + g *globals.Globals + ) + + app := newTestApp(t, &h, &sess, &g) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + g.Version = "v9.9.9-test" + + html := renderPage(t, h, sess, "login.html", map[string]any{ + dataKeyError: "", + }) + + assert.Contains(t, html, "v9.9.9-test") + assert.NotContains(t, html, "dev") +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 80aed4b..349771e 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -184,6 +184,7 @@ type UserInfo struct { type templateDataWrapper struct { User *UserInfo CSRFToken string + Version string Data any } @@ -234,9 +235,16 @@ func (s *Handlers) renderTemplate( userInfo := s.getUserInfo(r) csrfToken := middleware.CSRFToken(r) + // The footer in base.html renders .Version. Every page reaches it + // through here, so this is the one place that has to supply it; + // left unset, the footer falls back to its literal "dev" and the + // UI reports a build that is not the one running. + version := s.params.Globals.Version + if m, ok := data.(map[string]any); ok { m["User"] = userInfo m["CSRFToken"] = csrfToken + m["Version"] = version s.executeTemplate(w, tmpl, m) return @@ -245,6 +253,7 @@ func (s *Handlers) renderTemplate( wrapper := templateDataWrapper{ User: userInfo, CSRFToken: csrfToken, + Version: version, Data: data, } diff --git a/internal/versionscript/doc.go b/internal/versionscript/doc.go new file mode 100644 index 0000000..ee4dba4 --- /dev/null +++ b/internal/versionscript/doc.go @@ -0,0 +1,12 @@ +// Package versionscript holds the tests for script/version and for the +// build files that consume it. It carries no runtime code: the version +// string is produced by a shell script at build time and reaches the +// binary through a linker flag, so nothing in the Go build graph can +// assert it, but the behaviour still has to be verified by the test +// suite. +// +// The files under test are outside the Go build graph, so `go test`'s +// result cache serves a stale PASS when only script/version, the +// Makefile or the Dockerfile changed: run the container build, or +// GOFLAGS=-count=1, to trust a result here after editing them. +package versionscript diff --git a/internal/versionscript/version_script_test.go b/internal/versionscript/version_script_test.go new file mode 100644 index 0000000..8391545 --- /dev/null +++ b/internal/versionscript/version_script_test.go @@ -0,0 +1,289 @@ +package versionscript_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + scriptPath = "../../script/version" + makefilePath = "../../Makefile" + dockerfilePath = "../../Dockerfile" + + // unknown is what a tree with no git metadata and no $VERSION must + // report: a source tarball has no way to know its version, and the + // one thing it must not do is name a tag it may not be at. + unknown = "unknown" + + // scriptMode keeps the copied script runnable; dirMode and fileMode + // are the ordinary permissions for the throwaway tree around it. + scriptMode = 0o755 + dirMode = 0o750 + fileMode = 0o600 +) + +// checkout is a throwaway working tree carrying a copy of the script +// under test at the same path it lives at in this repository, since the +// script resolves the checkout root from its own location. +type checkout struct { + dir string + head string +} + +func TestVersion_CleanTagReportsExactlyTheTag(t *testing.T) { + t.Parallel() + + c := newCheckout(t) + c.git(t, "tag", "v1.2.3") + + require.Equal(t, "v1.2.3", c.version(t)) +} + +func TestVersion_CommitAfterTagCarriesDistanceAndSHA(t *testing.T) { + t.Parallel() + + c := newCheckout(t) + c.git(t, "tag", "v1.2.3") + head := c.commit(t, "after the tag") + + got := c.version(t) + + require.NotEqual(t, "v1.2.3", got, + "a commit past the tag must not claim to be the tag") + require.True(t, strings.HasPrefix(got, "v1.2.3-1-g"), + "want describe form v1.2.3-1-g, got %q", got) + require.True(t, strings.HasPrefix(head, strings.TrimPrefix( + got, "v1.2.3-1-g")), + "%q must carry the abbreviated head SHA of %q", got, head) +} + +func TestVersion_UntaggedHistoryReportsShortSHA(t *testing.T) { + t.Parallel() + + c := newCheckout(t) + + got := c.version(t) + + require.NotEmpty(t, got) + require.NotEqual(t, unknown, got) + require.True(t, strings.HasPrefix(c.head, got), + "%q must be an abbreviation of head %q", got, c.head) +} + +func TestVersion_UncommittedChangesAreMarkedDirty(t *testing.T) { + t.Parallel() + + c := newCheckout(t) + c.git(t, "tag", "v1.2.3") + + require.NoError(t, os.WriteFile( + filepath.Join(c.dir, "tracked.txt"), []byte("edited\n"), fileMode, + )) + + require.Equal(t, "v1.2.3-dirty", c.version(t)) +} + +// A source tarball, or any build context without .git, still has to +// build. It reports "unknown" rather than failing or naming a tag. +func TestVersion_NoGitMetadataReportsUnknown(t *testing.T) { + t.Parallel() + + c := newCheckout(t) + require.NoError(t, os.RemoveAll(filepath.Join(c.dir, ".git"))) + + require.Equal(t, unknown, c.version(t)) +} + +// An unpacked tarball can sit inside an unrelated working copy. The +// enclosing repository's version is not this tree's version. +func TestVersion_EnclosingRepositoryIsNotUsed(t *testing.T) { + t.Parallel() + + outer := newCheckout(t) + outer.git(t, "tag", "v9.9.9") + + inner := filepath.Join(outer.dir, "unpacked") + require.NoError(t, os.MkdirAll(filepath.Join(inner, "script"), dirMode)) + copyScript(t, inner) + + require.Equal(t, unknown, runScript(t, inner, nil)) +} + +// The Docker build has no git metadata, so the version arrives as an +// environment override. It wins over anything derivable. +func TestVersion_EnvironmentOverrideWins(t *testing.T) { + t.Parallel() + + c := newCheckout(t) + c.git(t, "tag", "v1.2.3") + + require.Equal(t, "v4.5.6", + runScript(t, c.dir, []string{"VERSION=v4.5.6"})) +} + +// An empty VERSION is treated as unset rather than stamping an empty +// string: the Dockerfile's build arg has a non-empty default, but a +// caller exporting VERSION= must not produce a binary reporting "". +func TestVersion_EmptyOverrideFallsBackToGit(t *testing.T) { + t.Parallel() + + c := newCheckout(t) + c.git(t, "tag", "v1.2.3") + + require.Equal(t, "v1.2.3", runScript(t, c.dir, []string{"VERSION="})) +} + +// Two builds of the same commit must produce a byte-identical binary, +// which they cannot if the stamped value moves between invocations. +func TestVersion_IsStableAcrossInvocations(t *testing.T) { + t.Parallel() + + c := newCheckout(t) + c.git(t, "tag", "v1.2.3") + + first := c.version(t) + second := c.version(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. +func TestMakefile_BuildComposesVersionAndExtraFlags(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, "VERSION ?= $(shell script/version)") +} + +// Every compile in the image goes through the build target, so the +// static relink cannot replace the flags that carry the stamp. +func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) { + t.Parallel() + + dockerfile := read(t, dockerfilePath) + + 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_LDFLAGS='-extldflags "-static"'`) +} + +func read(t *testing.T, path string) string { + t.Helper() + + //nolint:gosec // repo-local build file under test, fixed path + b, err := os.ReadFile(path) + require.NoError(t, err) + + return string(b) +} + +// version runs the script in this checkout with no overrides. +func (c checkout) version(t *testing.T) string { + t.Helper() + + return runScript(t, c.dir, nil) +} + +func runScript(t *testing.T, dir string, env []string) string { + t.Helper() + + //nolint:gosec // fixed argv, repo-local script under test + cmd := exec.CommandContext(t.Context(), "sh", + filepath.Join(dir, "script", "version")) + cmd.Dir = dir + + cmd.Env = append(os.Environ(), env...) + + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + + return strings.TrimSpace(string(out)) +} + +func (c checkout) git(t *testing.T, args ...string) string { + t.Helper() + + //nolint:gosec // fixed argv, arguments are test constants + cmd := exec.CommandContext(t.Context(), "git", args...) + cmd.Dir = c.dir + + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + + return strings.TrimSpace(string(out)) +} + +func (c checkout) commit(t *testing.T, message string) string { + t.Helper() + + c.git(t, + "-c", "user.email=ci@example.invalid", + "-c", "user.name=ci", + "-c", "commit.gpgsign=false", + "commit", "-q", "--allow-empty", "-m", message, + ) + + return c.git(t, "rev-parse", "HEAD") +} + +// newCheckout builds a one-commit repository with a tracked file, so a +// later edit to that file makes the tree dirty, and with a copy of the +// script at the path it occupies in this repository. +func newCheckout(t *testing.T) checkout { + t.Helper() + + requireGit(t) + + dir := t.TempDir() + c := checkout{dir: dir} + + require.NoError(t, os.MkdirAll(filepath.Join(dir, "script"), dirMode)) + copyScript(t, dir) + + require.NoError(t, os.WriteFile( + filepath.Join(dir, "tracked.txt"), []byte("original\n"), fileMode, + )) + + c.git(t, "init", "-q", "-b", "main") + c.git(t, "add", "tracked.txt") + c.head = c.commit(t, "initial") + + return c +} + +func copyScript(t *testing.T, dir string) { + t.Helper() + + body, err := os.ReadFile(scriptPath) + require.NoError(t, err) + + //nolint:gosec // the copy has to stay executable to be run + err = os.WriteFile( + filepath.Join(dir, "script", "version"), body, scriptMode, + ) + require.NoError(t, err) +} + +func requireGit(t *testing.T) { + t.Helper() + + for _, tool := range []string{"sh", "git"} { + _, err := exec.LookPath(tool) + if err != nil { + t.Skipf("%s is not installed: %v", tool, err) + } + } +} diff --git a/script/docker b/script/docker index 2884e41..ee26450 100755 --- a/script/docker +++ b/script/docker @@ -1,7 +1,10 @@ #!/bin/sh # script/docker: build the Docker image tagged with the project name. -# Identical in all repos; the tag comes from script/projectname. -# Generic: needs no adaptation. +# The tag comes from script/projectname. +# +# .dockerignore excludes .git/, so the builder stage cannot derive the +# version itself. It is resolved here, where the checkout is, and passed +# in as a build arg; without it the image would stamp itself "unknown". set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -9,7 +12,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build -t "$("$SCRIPT_DIR/projectname")" . + docker build \ + --build-arg VERSION="$("$SCRIPT_DIR/version")" \ + -t "$("$SCRIPT_DIR/projectname")" . } main "$@" diff --git a/script/version b/script/version new file mode 100755 index 0000000..71530dc --- /dev/null +++ b/script/version @@ -0,0 +1,65 @@ +#!/bin/sh +# script/version: output the version string the binary is stamped with. +# Our own extension to scripts-to-rule-them-all. The Makefile's build +# target and script/docker both take the value from here, so a `make +# build` binary and a `make docker` image built from the same checkout +# report the same thing. +# +# Order of precedence: +# +# 1. $VERSION, if set and non-empty. This is how the value reaches a +# build that cannot derive it: .dockerignore excludes .git/, so the +# builder stage has no git metadata and the Dockerfile takes the +# value as a build arg instead. +# 2. `git describe --tags --always --dirty` against this checkout. At +# a clean tagged commit that is exactly the tag; otherwise it +# carries the short SHA, the commit distance when a tag is +# reachable, and a -dirty suffix for uncommitted changes. +# 3. "unknown", for a tree with no git metadata and no $VERSION -- a +# source tarball, or `docker build .` with no --build-arg. That +# case must not fail the build and must not name a tag the tree may +# not be at, so it names nothing. +# +# The git step insists the enclosing repository is this checkout, not +# merely some repository above it: an unpacked tarball sitting inside an +# unrelated working copy would otherwise be stamped with that copy's +# version. +# +# Nothing here may vary between two builds of the same commit: the +# release gate asserts the binary is byte-identical across builds. That +# rules out a build timestamp, a hostname, and a builder identity. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +# in_this_checkout succeeds when git can read metadata for a repository +# whose work tree root is $ROOT. +in_this_checkout() { + command -v git >/dev/null 2>&1 || return 1 + + top="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1 + [ -n "$top" ] || return 1 + + top="$(cd "$top" 2>/dev/null && pwd -P)" || return 1 + [ "$top" = "$ROOT" ] +} + +main() { + if [ -n "${VERSION:-}" ]; then + echo "$VERSION" + + return 0 + fi + + cd "$ROOT" + + if in_this_checkout; then + # --always keeps an untagged history from failing the build: it + # falls back to the bare short SHA. + git describe --tags --always --dirty 2>/dev/null && return 0 + fi + + echo "unknown" +} + +main "$@"