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 "$@"