Derive the image's version from the .git in the build context (closes #366)
check / check (push) Successful in 4m36s

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 without its config, which can carry a credential, and every
tracked file (an excluded one would read as deleted and mark the version
-dirty). The VERSION build arg loses its "unknown" default, so
script/version derives the version inside the build; a given VERSION
still takes precedence.

The builder stage installs git, trusts the copied checkout whoever owns
its files, and fails when its context carries .git and the version still
comes out "unknown". The CI fingerprint is now the commit being checked.

Model: opus-5-5
This commit is contained in:
2026-10-02 02:28:33 +00:00
parent bfdbc937c6
commit defccd38f6
8 changed files with 85 additions and 68 deletions
+8 -4
View File
@@ -1,14 +1,18 @@
# .git is sent, without its config, so the build can derive the version it
# stamps into the binary (script/version). The config can hold a remote URL
# carrying a credential, and `git describe` does not need it.
.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 # .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
+7 -13
View File
@@ -12,9 +12,8 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
with: with:
# The fingerprint step below needs history to find the last commit # The superseded-status step needs history to walk ancestors (it
# that touched the Docker build context, and the superseded-status # aborts on a shallow clone).
# step needs it to walk ancestors (it aborts on a shallow clone).
fetch-depth: 0 fetch-depth: 0
- name: Mark superseded run statuses - name: Mark superseded run statuses
@@ -28,16 +27,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 # Writes the hash of the commit being checked into the context, which
# commit legitimately replays the whole image from cache and stays # invalidates the `COPY . .` layer of both check stages: a commit
# cheap. Every other commit writes a new fingerprint into the context, # that was never linted, format-checked, tested and built cannot
# which invalidates the `COPY . .` layer of both check stages: a # report success from cache.
# commit that was never linted, formatted-checked, tested and built run: git rev-parse HEAD > .ci-fingerprint
# 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
- name: Build Docker image (runs make check) - name: Build Docker image (runs make check)
run: script/cibuild run: script/cibuild
+22 -9
View File
@@ -12,8 +12,8 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# Copy source code. In CI the context also carries .ci-fingerprint, whose # Copy source code. In CI the context also carries .ci-fingerprint, which
# value changes with every commit that touches the build context (see # holds the hash of the commit being checked (see
# .gitea/workflows/check.yml). That invalidates this layer, so the checks # .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 # below cannot report success by replaying a cached pass. Do not add it to
# .dockerignore. # .dockerignore.
@@ -38,8 +38,13 @@ 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/*
# 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 WORKDIR /build
@@ -55,14 +60,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 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" 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
+29 -21
View File
@@ -1133,13 +1133,26 @@ 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, with no build
the build context carries no git metadata and the image cannot derive arguments, stamps the commit it was built from; a shallow clone of one
the version itself: `script/docker` (and so `make docker`) resolves it branch has no tags and stamps the short SHA. `.dockerignore` must
on the host and passes it in as the `VERSION` build arg. A build that therefore leave out neither `.git` nor any tracked file, which git in
reports `unknown` is a build nobody told what it was; it is not a the build would see as deleted, marking the version `-dirty`. It does
failure, but it cannot be traced back to a commit. leave out `.git/config`, which can hold a remote URL carrying a
credential and which `git describe` does not need. 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 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
@@ -3174,8 +3187,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`
@@ -3207,19 +3221,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 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 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 The module download layer sits above `COPY . .` and stays cached.
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.
A separate workflow step, run before the fingerprint is written, covers 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 a second way the gate lied: Gitea cancels an in-flight run when a newer
@@ -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