1 Commits
Author SHA1 Message Date
sneak 3502fac119 Derive the image's version from the .git in the build context (closes #366)
check / check (push) Waiting to run
A plain docker build, as upaas runs it, passed no VERSION build arg and
had no .git, so every such image stamped "unknown". .dockerignore now
sends .git and every tracked file (an excluded one would read as
deleted and mark the version -dirty), and the VERSION build arg loses
its "unknown" default, so script/version derives the version inside the
build. A VERSION build arg still takes precedence.

The builder stage installs git and fails when its context carries .git
and the version still comes out "unknown".

Model: opus-5-5
2026-10-01 22:08:02 +00:00
8 changed files with 67 additions and 51 deletions
+5 -4
View File
@@ -1,14 +1,15 @@
# .git is deliberately NOT excluded: the build derives the version it stamps
# into the binary from it (script/version). Nor is any tracked file: git in
# the build would see it as deleted and mark the version -dirty. Only
# untracked files belong here.
#
# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier # .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier
# that keeps the check stages from replaying a cached pass. See the lint # that keeps the check stages from replaying a cached pass. See the lint
# stage of the Dockerfile. # stage of the Dockerfile.
.git/
bin/ bin/
# Extracted from 3p/ by `make assets` inside the build; a host copy is not # Extracted from 3p/ by `make assets` inside the build; a host copy is not
# needed. The tarball in 3p/ must stay in the context. # needed. The tarball in 3p/ must stay in the context.
static/js/alpine.min.js static/js/alpine.min.js
*.md
LICENSE
.editorconfig
.env .env
.env.* .env.*
*.db *.db
+5 -6
View File
@@ -28,12 +28,11 @@ jobs:
run: script/ci-mark-superseded run: script/ci-mark-superseded
- name: Fingerprint the build context - name: Fingerprint the build context
# `.dockerignore` keeps docs out of the build context, so a docs-only # Every commit that changes more than docs writes a new fingerprint
# commit legitimately replays the whole image from cache and stays # into the context, which invalidates the `COPY . .` layer of both
# cheap. Every other commit writes a new fingerprint into the context, # check stages: a commit that was never linted, formatted-checked,
# which invalidates the `COPY . .` layer of both check stages: a # tested and built cannot report success from cache. Docs-only
# commit that was never linted, formatted-checked, tested and built # commits rebuild too, since the context also carries `.git`.
# cannot report success from cache.
run: | run: |
set -eu set -eu
fp="$(git log -1 --format=%H -- . ':!*.md' ':!LICENSE' ':!.editorconfig')" fp="$(git log -1 --format=%H -- . ':!*.md' ':!LICENSE' ':!.editorconfig')"
+15 -7
View File
@@ -38,8 +38,8 @@ FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a349228
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
# jq is a runtime dependency of script/ci-mark-superseded, which the test # jq is a runtime dependency of script/ci-mark-superseded, which the test
# suite executes. # suite executes. git is what script/version derives the version with.
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq git && rm -rf /var/lib/apt/lists/*
WORKDIR /build WORKDIR /build
@@ -55,14 +55,22 @@ COPY . .
# from its tarball in 3p/. # from its tarball in 3p/.
RUN make test RUN make test
# Version stamped into the binary. .dockerignore excludes .git/, so # Version stamped into the binary: the VERSION build arg when one is
# nothing in this stage can derive it: script/docker resolves it on the # given, otherwise what script/version derives from the .git the build
# host and passes it in. The default is what a bare `docker build .` # context carries, so any `docker build .` of a clone stamps its commit.
# with no --build-arg gets, and it names no tag the tree may not be at. # With neither, as from a source tarball, it is "unknown".
# #
# Declared here, below the test step, so a changed version does not # Declared here, below the test step, so a changed version does not
# invalidate its cached layer. # invalidate its cached layer.
ARG VERSION=unknown ARG VERSION
# A context that carries .git must not stamp "unknown": that means git is
# missing here or refused to read the checkout, and the image could not be
# traced back to its commit.
RUN if [ -d .git ] && [ "$(make version VERSION="$VERSION")" = unknown ]; then \
echo "version is unknown although the build context carries .git" >&2; \
exit 1; \
fi
RUN make build VERSION="$VERSION" RUN make build VERSION="$VERSION"
+4 -4
View File
@@ -4,12 +4,12 @@
.DEFAULT_GOAL := check .DEFAULT_GOAL := check
# Version stamped into the binary. Derived from git by script/version; # Version stamped into the binary. Derived from git by script/version;
# override it (`make build VERSION=v1.2.3`) where git metadata is # override it (`make build VERSION=v1.2.3`) to stamp a given value, which is
# unavailable, which is how the Dockerfile passes its build arg in. # how the Dockerfile passes its build arg in.
VERSION ?= $(shell script/version) VERSION ?= $(shell script/version)
# An empty override (`make build VERSION=`, or a `--build-arg VERSION=` # An empty override (`make build VERSION=`, or the Dockerfile's `make build
# landing on the Dockerfile's `make build VERSION="$VERSION"`) means unset, # VERSION="$VERSION"` when no VERSION build arg was given) means unset,
# exactly as it does in script/version -- stamping "" would leave the binary # exactly as it does in script/version -- stamping "" would leave the binary
# reporting no version and the footer back on its "dev" fallback. `override` # reporting no version and the footer back on its "dev" fallback. `override`
# is required: a plain assignment loses to the command-line definition it # is required: a plain assignment loses to the command-line definition it
+23 -13
View File
@@ -1123,13 +1123,21 @@ build itself.
| Uncommitted changes | the above with a `-dirty` suffix | | Uncommitted changes | the above with a `-dirty` suffix |
| No git metadata | `unknown` | | No git metadata | `unknown` |
`unknown` is what a source tarball or a `docker build .` with no The image derives it the same way, from the `.git` that the build
`--build-arg VERSION=...` reports. `.dockerignore` excludes `.git/`, so context carries, so any `docker build .` of a clone stamps the commit it
the build context carries no git metadata and the image cannot derive was built from; a shallow clone of one branch has no tags and stamps the
the version itself: `script/docker` (and so `make docker`) resolves it short SHA. `.dockerignore` must therefore leave out neither `.git` nor
on the host and passes it in as the `VERSION` build arg. A build that any tracked file, which git in the build would see as deleted, marking
reports `unknown` is a build nobody told what it was; it is not a the version `-dirty`. A `VERSION` build arg (`--build-arg VERSION=...`)
failure, but it cannot be traced back to a commit. takes precedence; `script/docker` (and so `make docker`) passes the one
`script/version` resolves on the host. The image build fails if its
context carries `.git` and the version still comes out `unknown`, which
means git in the build could not read the checkout.
`unknown` is what a source tarball, or a `docker build` with no `.git`
in its context and no `VERSION` build arg, reports. 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 version` prints what the current checkout would stamp, and
`make build VERSION=v1.2.3` overrides it. An empty override — from `make build VERSION=v1.2.3` overrides it. An empty override — from
@@ -3165,8 +3173,9 @@ version is fixed independently of the compiler's:
rebuilds the binary with `CGO_ENABLED=1` and static linking so it rebuilds the binary with `CGO_ENABLED=1` and static linking so it
runs on musl. Both builds go through `make build`, the relink adding runs on musl. Both builds go through `make build`, the relink adding
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
stamps the version. The version arrives as the `VERSION` build arg, stamps the version. The version is the `VERSION` build arg if one is
since the context has no `.git` (see given, otherwise derived from the `.git` in the context, and the
stage fails if a context with `.git` would stamp `unknown` (see
[Version stamping](#version-stamping)). [Version stamping](#version-stamping)).
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and 3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` `deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
@@ -3198,16 +3207,17 @@ A layer cache lets `docker build .` exit 0 in seconds with the lint and
test stages replayed rather than executed, which would make a green test stages replayed rather than executed, which would make a green
check meaningless. The `check` workflow therefore writes check meaningless. The `check` workflow therefore writes
`.ci-fingerprint` into the build context before building. Its value is `.ci-fingerprint` into the build context before building. Its value is
the hash of the last commit that touched the build context, so: the hash of the last commit that touched anything other than `*.md`,
`LICENSE` and `.editorconfig`, so:
- Any commit that changes code (including a squash merge whose tree - Any commit that changes code (including a squash merge whose tree
matches an already-built branch) gets a new fingerprint, invalidates matches an already-built branch) gets a new fingerprint, invalidates
the `COPY . .` layer of both check stages, and really runs the `COPY . .` layer of both check stages, and really runs
`make fmt-check`, `golangci-lint`, `make test`, and `make build`. A `make fmt-check`, `golangci-lint`, `make test`, and `make build`. A
run that reports success ran them. run that reports success ran them.
- A docs-only commit leaves the fingerprint unchanged — `.dockerignore` - A docs-only commit leaves the fingerprint unchanged, but it still
excludes `*.md`, `LICENSE` and `.editorconfig` from the context rebuilds in full: the context also carries `.git`, which changes with
anyway — so the image replays from cache and costs seconds. every commit (see [Version stamping](#version-stamping)).
The module download layer sits above `COPY . .` and stays cached either The module download layer sits above `COPY . .` and stays cached either
way. way.
@@ -115,8 +115,8 @@ func TestVersion_EnclosingRepositoryIsNotUsed(t *testing.T) {
require.Equal(t, unknown, runScript(t, inner, nil)) require.Equal(t, unknown, runScript(t, inner, nil))
} }
// The Docker build has no git metadata, so the version arrives as an // An explicit VERSION, such as the Dockerfile's build arg, wins over
// environment override. It wins over anything derivable. // anything derivable.
func TestVersion_EnvironmentOverrideWins(t *testing.T) { func TestVersion_EnvironmentOverrideWins(t *testing.T) {
t.Parallel() t.Parallel()
@@ -128,8 +128,8 @@ func TestVersion_EnvironmentOverrideWins(t *testing.T) {
} }
// An empty VERSION is treated as unset rather than stamping an empty // 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 // string: a caller exporting VERSION= must not produce a binary
// caller exporting VERSION= must not produce a binary reporting "". // reporting "".
func TestVersion_EmptyOverrideFallsBackToGit(t *testing.T) { func TestVersion_EmptyOverrideFallsBackToGit(t *testing.T) {
t.Parallel() t.Parallel()
@@ -168,8 +168,8 @@ func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) {
} }
// A caller can define VERSION as the empty string -- `make build // A caller can define VERSION as the empty string -- `make build
// VERSION=`, or a `--build-arg VERSION=` reaching the Dockerfile's `make // VERSION=`, or the Dockerfile's `make build VERSION="$VERSION"` when no
// build VERSION="$VERSION"`. script/version's own guard does not cover // VERSION build arg was given. script/version's own guard does not cover
// that: the value never passes through the script. Stamping "" would // that: the value never passes through the script. Stamping "" would
// leave the binary reporting no version and the footer on "dev", which // leave the binary reporting no version and the footer on "dev", which
// is the defect this package exists for. // is the defect this package exists for.
@@ -231,7 +231,7 @@ func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) {
require.NotContains(t, dockerfile, "go build", require.NotContains(t, dockerfile, "go build",
"a raw go build bypasses the Makefile's -X flag") "a raw go build bypasses the Makefile's -X flag")
require.Contains(t, dockerfile, "ARG VERSION=") require.Contains(t, dockerfile, "ARG VERSION")
require.Contains(t, dockerfile, require.Contains(t, dockerfile,
`make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'`) `make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'`)
} }
+3 -3
View File
@@ -2,9 +2,9 @@
# script/docker: build the Docker image tagged with the project name. # script/docker: build the Docker image tagged with the project name.
# The tag comes from script/projectname. # The tag comes from script/projectname.
# #
# .dockerignore excludes .git/, so the builder stage cannot derive the # The version script/version resolves here goes in as the VERSION build
# version itself. It is resolved here, where the checkout is, and passed # arg, which takes precedence over what the build would derive from the
# in as a build arg; without it the image would stamp itself "unknown". # .git in its context.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
+5 -7
View File
@@ -7,18 +7,16 @@
# #
# Order of precedence: # Order of precedence:
# #
# 1. $VERSION, if set and non-empty. This is how the value reaches a # 1. $VERSION, if set and non-empty: an explicit value, such as the
# build that cannot derive it: .dockerignore excludes .git/, so the # Dockerfile's VERSION build arg.
# 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 # 2. `git describe --tags --always --dirty` against this checkout. At
# a clean tagged commit that is exactly the tag; otherwise it # a clean tagged commit that is exactly the tag; otherwise it
# carries the short SHA, the commit distance when a tag is # carries the short SHA, the commit distance when a tag is
# reachable, and a -dirty suffix for uncommitted changes. # reachable, and a -dirty suffix for uncommitted changes.
# 3. "unknown", for a tree with no git metadata and no $VERSION -- a # 3. "unknown", for a tree with no git metadata and no $VERSION -- a
# source tarball, or `docker build .` with no --build-arg. That # source tarball, or a `docker build` with no .git in its context
# case must not fail the build and must not name a tag the tree may # and no VERSION build arg. That case must not fail the build and
# not be at, so it names nothing. # 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 # The git step insists the enclosing repository is this checkout, not
# merely some repository above it: an unpacked tarball sitting inside an # merely some repository above it: an unpacked tarball sitting inside an