Derive the image's version from the .git in the build context (closes #366) #410

Open
clawbot wants to merge 1 commits from issue-366-version-from-git into next
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