From cb7bafab17b8764629cbe0f2d554718ab50d57b4 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 08:41:20 +0200 Subject: [PATCH] Derive the image's version from the .git in the build context (closes #366) upaas uploads its clone as a tar context, which .dockerignore does not filter, so its builds already carried .git; the unknown default of the VERSION build arg is what stamped them. The image now derives its version itself: ARG VERSION has no default, so make build falls back to script/version, which runs git describe on the copied .git; a VERSION build arg still wins. .dockerignore sends .git without its config in a directory context and no longer leaves out tracked files, which would mark the tree -dirty. The builder installs git, trusts /build as a safe.directory, and fails when .git is present but the version comes out unknown. A shallow single-branch clone stamps its short commit. Model: opus-5-5 --- .dockerignore | 14 +++-- .gitea/workflows/check.yml | 20 +++---- Dockerfile | 31 +++++++---- Makefile | 8 +-- README.md | 53 +++++++++++-------- internal/versionscript/version_script_test.go | 14 ++--- script/docker | 6 +-- script/version | 12 ++--- 8 files changed, 90 insertions(+), 68 deletions(-) diff --git a/.dockerignore b/.dockerignore index 550060e..7d52bd5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,14 +1,20 @@ +# .git is sent so the build can derive the version it stamps into the binary +# (script/version). Its config, which can hold a remote URL carrying a +# credential and which `git describe` does not need, is left out of a +# directory context. A context sent as a tar is not filtered by this file, so +# it carries .git/config unless its sender leaves it out. +.git/config + +# No tracked file may be listed here: git in the build would see it as +# deleted and mark the version -dirty. +# # .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 # stage of the Dockerfile. -.git/ bin/ # Extracted from 3p/ by `make assets` inside the build; a host copy is not # needed. The tarball in 3p/ must stay in the context. static/js/alpine.min.js -*.md -LICENSE -.editorconfig .env .env.* *.db diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 562ec31..d4ab6d3 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -12,9 +12,8 @@ jobs: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23 with: - # The fingerprint step below needs history to find the last commit - # that touched the Docker build context, and the superseded-status - # step needs it to walk ancestors (it aborts on a shallow clone). + # The superseded-status step needs history to walk ancestors (it + # aborts on a shallow clone). fetch-depth: 0 - name: Mark superseded run statuses @@ -28,16 +27,11 @@ jobs: run: script/ci-mark-superseded - name: Fingerprint the build context - # `.dockerignore` keeps docs out of the build context, so a docs-only - # commit legitimately replays the whole image from cache and stays - # cheap. Every other commit writes a new fingerprint into the context, - # which invalidates the `COPY . .` layer of both check stages: a - # commit that was never linted, formatted-checked, tested and built - # cannot report success from cache. - run: | - set -eu - fp="$(git log -1 --format=%H -- . ':!*.md' ':!LICENSE' ':!.editorconfig')" - printf '%s\n' "${fp:-$GITHUB_SHA}" > .ci-fingerprint + # Writes the hash of the commit being checked into the context, which + # invalidates the `COPY . .` layer of both check stages: a commit + # that was never linted, format-checked, tested and built cannot + # report success from cache. + run: git rev-parse HEAD > .ci-fingerprint - name: Build Docker image (runs make check) run: script/cibuild diff --git a/Dockerfile b/Dockerfile index d9c5bc7..bcc199d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,8 +12,8 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download -# Copy source code. In CI the context also carries .ci-fingerprint, whose -# value changes with every commit that touches the build context (see +# Copy source code. In CI the context also carries .ci-fingerprint, which +# holds the hash of the commit being checked (see # .gitea/workflows/check.yml). That invalidates this layer, so the checks # below cannot report success by replaying a cached pass. Do not add it to # .dockerignore. @@ -38,8 +38,13 @@ FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a349228 COPY --from=lint /src/go.sum /dev/null # jq is a runtime dependency of script/ci-mark-superseded, which the test -# suite executes. -RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq && rm -rf /var/lib/apt/lists/* +# 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 git && rm -rf /var/lib/apt/lists/* + +# A build context sent as a tar archive keeps its files' owners, and git +# refuses to read a checkout owned by another user. Trust this one +# whoever owns it. +RUN git config --system --add safe.directory /build WORKDIR /build @@ -55,14 +60,22 @@ COPY . . # from its tarball in 3p/. RUN make test -# 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. +# Version stamped into the binary: the VERSION build arg when one is +# given, otherwise what script/version derives from the .git the build +# context carries, so any `docker build .` of a clone stamps its commit. +# With neither, as from a source tarball, it is "unknown". # # Declared here, below the test step, so a changed version does not # 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 could not 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" diff --git a/Makefile b/Makefile index b5438f8..981e7ae 100644 --- a/Makefile +++ b/Makefile @@ -4,12 +4,12 @@ .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. +# override it (`make build VERSION=v1.2.3`) to stamp a given value, 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, +# An empty override (`make build VERSION=`, or the Dockerfile's `make build +# VERSION="$VERSION"` when no VERSION build arg was given) 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 diff --git a/README.md b/README.md index 86b1a1a..dc4a10d 100644 --- a/README.md +++ b/README.md @@ -1133,13 +1133,29 @@ build itself. | 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. +The image derives it the same way, from the `.git` that the build +context carries, so any `docker build .` of a clone, with no build +arguments, stamps the commit it was built from; a shallow clone of one +branch has no tags and stamps the short SHA. `.dockerignore` must +therefore leave out neither `.git` nor any tracked file, which git in +the build would see as deleted, marking the version `-dirty`. It does +leave `.git/config`, which can hold a remote URL carrying a credential +and which `git describe` does not need, out of a directory context. A +context sent as a tar is not filtered by `.dockerignore`, so it carries +`.git/config` unless its sender leaves it out; for upaas, that is +https://git.eeqj.de/sneak/upaas/issues/274. git in the build +reads the checkout whoever owns its files, since a context sent as a tar +archive keeps the sender's owners and git otherwise refuses a checkout +owned by another user. A `VERSION` build arg (`--build-arg VERSION=...`) +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 is missing from the build or 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 build VERSION=v1.2.3` overrides it. An empty override — from @@ -3233,8 +3249,9 @@ version is fixed independently of the compiler's: rebuilds the binary with `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 + stamps the version. The version is the `VERSION` build arg if one is + given, otherwise derived from the `.git` in the context, and the + stage fails if a context with `.git` would stamp `unknown` (see [Version stamping](#version-stamping)). 3. **Runtime stage** (`alpine:3.21`) — copies the static binary and `deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` @@ -3266,19 +3283,13 @@ 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 check meaningless. The `check` workflow therefore writes `.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 commit being checked, so every commit, docs-only ones +and a squash merge whose tree matches an already-built branch included, +gets a new fingerprint, invalidates the `COPY . .` layer of both check +stages, and really runs `make fmt-check`, `golangci-lint`, `make test`, +and `make build`. A run that reports success ran them. -- Any commit that changes code (including a squash merge whose tree - matches an already-built branch) gets a new fingerprint, invalidates - the `COPY . .` layer of both check stages, and really runs - `make fmt-check`, `golangci-lint`, `make test`, and `make build`. A - run that reports success ran them. -- A docs-only commit leaves the fingerprint unchanged — `.dockerignore` - excludes `*.md`, `LICENSE` and `.editorconfig` from the context - anyway — so the image replays from cache and costs seconds. - -The module download layer sits above `COPY . .` and stays cached either -way. +The module download layer sits above `COPY . .` and stays cached. A separate workflow step, run before the fingerprint is written, covers a second way the gate lied: Gitea cancels an in-flight run when a newer diff --git a/internal/versionscript/version_script_test.go b/internal/versionscript/version_script_test.go index 734e3fe..3e59b17 100644 --- a/internal/versionscript/version_script_test.go +++ b/internal/versionscript/version_script_test.go @@ -115,8 +115,8 @@ func TestVersion_EnclosingRepositoryIsNotUsed(t *testing.T) { 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. +// An explicit VERSION, such as the Dockerfile's build arg, wins over +// anything derivable. func TestVersion_EnvironmentOverrideWins(t *testing.T) { t.Parallel() @@ -128,8 +128,8 @@ func TestVersion_EnvironmentOverrideWins(t *testing.T) { } // 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 "". +// string: a caller exporting VERSION= must not produce a binary +// reporting "". func TestVersion_EmptyOverrideFallsBackToGit(t *testing.T) { t.Parallel() @@ -168,8 +168,8 @@ func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) { } // 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 +// VERSION=`, or the Dockerfile's `make build VERSION="$VERSION"` when no +// VERSION build arg was given. 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. @@ -231,7 +231,7 @@ func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) { require.NotContains(t, dockerfile, "go build", "a raw go build bypasses the Makefile's -X flag") - require.Contains(t, dockerfile, "ARG VERSION=") + require.Contains(t, dockerfile, "ARG VERSION") require.Contains(t, dockerfile, `make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'`) } diff --git a/script/docker b/script/docker index ee26450..ba8a836 100755 --- a/script/docker +++ b/script/docker @@ -2,9 +2,9 @@ # script/docker: build the Docker image tagged with the project name. # 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". +# The version script/version resolves here goes in as the VERSION build +# arg, which takes precedence over what the build would derive from the +# .git in its context. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" diff --git a/script/version b/script/version index 71530dc..9fbce61 100755 --- a/script/version +++ b/script/version @@ -7,18 +7,16 @@ # # 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. +# 1. $VERSION, if set and non-empty: an explicit value, such as the +# Dockerfile's VERSION build arg. # 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. +# source tarball, or a `docker build` with no .git in its context +# and no VERSION 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