From fec6876c42a31f9c3ebaa1baa00aa9ee7d3f0596 Mon Sep 17 00:00:00 2001 From: sneak Date: Sun, 23 Aug 2026 23:11:56 +0000 Subject: [PATCH] Stamp the build version into the binary (closes #253) The binary reported "dev" in every deployment: main.version carried a placeholder and nothing ever set it. Neither `make build` nor the Dockerfile passed -X, so a tagged release produced an artifact that could not say which commit it was, and the upgrade procedure's "confirm the new build is live" step had nothing to confirm against. script/version is now the single source of the value: $VERSION when set, else `git describe --tags --always --dirty`, else "unknown". A clean checkout at a tag reports exactly that tag; a tree with no git metadata reports "unknown" rather than failing or naming a tag it may not be at. Nothing time- or host-dependent is stamped, so two builds of one commit stay byte-identical. The Makefile's build target composes the flags -- `-X main.version` plus whatever GO_LDFLAGS adds -- and every compile goes through it, including the Dockerfile's static relink, which now contributes its -extldflags through GO_LDFLAGS instead of replacing -ldflags wholesale. Since .dockerignore excludes .git/, the image cannot derive the version: it takes a VERSION build arg, defaulted to "unknown", that script/docker fills in from the host checkout. The UI footer needed the other half of the fix. It renders .Version, which nothing ever put in the template data, so it printed its literal "dev" fallback no matter what the binary was built as; renderTemplate now supplies the value on both the map and the wrapper path. An empty VERSION means unset in the Makefile too, not only in script/version: `make build VERSION=` and a `--build-arg VERSION=` reaching the Dockerfile's `make build VERSION="$VERSION"` both define the variable as the empty string, which the script's guard never sees and which would stamp no version at all -- putting the footer back on its "dev" fallback, the defect this change exists to remove. The guard needs `override`: a plain assignment loses to the command-line definition it exists to correct. --- Dockerfile | 18 +- Makefile | 25 +- README.md | 64 +++- internal/handlers/footer_version_test.go | 38 ++ internal/handlers/handlers.go | 9 + internal/versionscript/doc.go | 12 + internal/versionscript/version_script_test.go | 345 ++++++++++++++++++ script/docker | 11 +- script/version | 65 ++++ 9 files changed, 573 insertions(+), 14 deletions(-) create mode 100644 internal/handlers/footer_version_test.go create mode 100644 internal/versionscript/doc.go create mode 100644 internal/versionscript/version_script_test.go create mode 100755 script/version 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..ceed419 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,26 @@ -.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) + +# An empty override (`make build VERSION=`, or a `--build-arg VERSION=` +# landing on the Dockerfile's `make build VERSION="$VERSION"`) means unset, +# exactly as it does in script/version -- stamping "" would leave the binary +# reporting no version and the footer back on its "dev" fallback. `override` +# is required: a plain assignment loses to the command-line definition it +# exists to correct. +override VERSION := $(or $(strip $(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 +46,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 +58,9 @@ deps: go mod download go mod tidy +version: + @echo $(VERSION) + docker: @script/docker diff --git a/README.md b/README.md index 43b9565..e1a56a6 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,42 @@ 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. An empty override — from +`make build VERSION=` or from `--build-arg VERSION=` — means unset +rather than `""`, and resolves the way an absent one does. + +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 +869,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 +886,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 @@ -2413,7 +2459,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 ``` @@ -2713,7 +2759,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..734e3fe --- /dev/null +++ b/internal/versionscript/version_script_test.go @@ -0,0 +1,345 @@ +package versionscript_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + repoRoot = "../.." + 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)") +} + +// A caller can define VERSION as the empty string -- `make build +// VERSION=`, or a `--build-arg VERSION=` reaching the Dockerfile's `make +// build VERSION="$VERSION"`. script/version's own guard does not cover +// that: the value never passes through the script. Stamping "" would +// leave the binary reporting no version and the footer on "dev", which +// is the defect this package exists for. +func TestMakefile_EmptyOverrideResolvesLikeAnUnsetOne(t *testing.T) { + t.Parallel() + + // A plain assignment would be ignored here: a command-line + // definition outranks it, and that is the case being corrected. + require.Contains(t, read(t, makefilePath), "override VERSION :=") + + requireMake(t) + + derived := makeVersion(t) + require.NotEmpty(t, derived) + + require.Equal(t, derived, makeVersion(t, "VERSION="), + "an empty VERSION must resolve the way an unset one does") + require.Equal(t, "v9.9.9", makeVersion(t, "VERSION=v9.9.9"), + "the empty guard must not clobber a real override") +} + +// makeVersion runs this repository's `version` target, which prints the +// value `make build` would stamp, with the given command-line +// definitions. +func makeVersion(t *testing.T, defs ...string) string { + t.Helper() + + //nolint:gosec // fixed argv, arguments are test constants + cmd := exec.CommandContext(t.Context(), "make", + append([]string{"--no-print-directory", "version"}, defs...)...) + cmd.Dir = repoRoot + + // Only the command-line definitions may decide the outcome: an + // inherited VERSION would change what an unset one resolves to, and + // an inherited MAKEFLAGS carries the parent's jobserver. + cmd.Env = append(os.Environ(), "VERSION=", "MAKEFLAGS=", "MAKELEVEL=") + + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + + return strings.TrimSpace(string(out)) +} + +func requireMake(t *testing.T) { + t.Helper() + + _, err := exec.LookPath("make") + if err != nil { + t.Skipf("make is not installed: %v", err) + } +} + +// 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 "$@" -- 2.49.1