From 877eb2f755bf3b9574e58ba1008abe58ec91b1c7 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 05:21:11 +0000 Subject: [PATCH] Stamp the tag or short commit in a plain docker build (closes #211) A plain `docker build .` stamped `dev`: `.dockerignore` left out `.git` and the Dockerfile defaulted VERSION to `dev`. `.dockerignore` is now the canonical one plus this repo's entries, sending `.git` without `.git/config`. Given no build arguments, the builder stamps `git describe --tags --always` and the commit and date from git, and fails if `.git` is present but yields no version. The empty CHECK_EPOCH refusal is gone so the plain build succeeds; the scripts still pass an epoch. `script/version` now prints `git describe --tags --always --dirty`, so make, the scripts and a plain build agree. Model: opus-5-5 --- .dockerignore | 64 +++++++++++++++++++++++++- .goreleaser.yaml | 4 +- Dockerfile | 58 +++++++++++------------- Makefile | 4 +- README.md | 64 +++++++++++++++----------- cmd/vaultik/dockerversion_test.go | 59 +++++++++++++----------- cmd/vaultik/lintdocker_test.go | 15 ++++--- internal/globals/globals.go | 18 ++++---- internal/globals/globals_test.go | 10 ++--- script/cibuild | 18 ++++---- script/docker | 23 +++++----- script/version | 75 +++++++------------------------ 12 files changed, 218 insertions(+), 194 deletions(-) diff --git a/.dockerignore b/.dockerignore index fc84c76..0228778 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,65 @@ -.git +# .dockerignore does NOT use .gitignore semantics. Docker matches with +# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross +# `/` and an unprefixed pattern is anchored at the context root. Every +# depth-independent pattern therefore needs `**/`, or `config/.env` and +# `certs/server.key` still ship while this file reads as solved. Only +# genuinely root-anchored entries go unprefixed. Never transplant these +# into .gitignore, where `**/` is wrong. +# +# Matching is case-sensitive, so secrets use character ranges rather +# than an ALL-CAPS twin, which would still miss `Server.Key`. +# +# Extend with this repo's own host-built artifacts, written anchored: +# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and +# deletes the package directory from the context. + +# .git is sent without its config. Without a VERSION build argument the +# stage that compiles runs `git describe --tags --always` on .git, which +# does not need .git/config; that file can hold a credential, such as a +# password in a remote URL or the token the CI checkout step stores there. +.git/config + +# Agent scratch: one full checkout of the repo per in-flight agent. +# Anchored because it occurs once where agents run at the repo root. +# KNOWN GAP: a repo running agents in subdirectories still ships +# `services/api/.claude/` and must add its own anchored entry. +.claude + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Re-include a committed template with a negation if the +# build needs one: `!docs/example.env`. +**/*.[eE][nN][vV] +**/.[eE][nN][vV].* +**/.[eE][nN][vV][rR][cC] + +# Private keys and the bundles carrying them. Public certificates +# (*.crt, *.cer) are deliberately absent: they are legitimate inputs. +**/*.[pP][eE][mM] +**/*.[kK][eE][yY] +**/*.[pP]12 +**/*.[pP][fF][xX] +**/[iI][dD]_[rR][sS][aA] +**/[iI][dD]_[dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA] +**/[iI][dD]_[eE][dD]25519 + +# Dependencies: restored inside the image, never copied in. +**/node_modules + +# OS metadata. +**/.DS_Store +**/Thumbs.db + +# Editor state: never a build input, and it churns COPY. +**/*.swp +**/*.swo +**/*~ +**/*.bak +**/.idea +**/.vscode +**/*.sublime-* + +# This repo's own entries. .gitea *.md LICENSE @@ -7,4 +68,3 @@ dist .tool coverage.out coverage.html -.DS_Store diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d316d84..7f168b9 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -47,9 +47,7 @@ checksum: # A snapshot is not a release and must not name itself like one. The # previous `{{ incpatch .Version }}-next` derived a plausible-looking # release number from the last tag -- and with no tags in the repo at -# all, from goreleaser's fabricated v0.0.0. This produces the same -# string script/version produces for an untagged build, so a snapshot -# binary and a `make vaultik` binary of the same clean commit agree. +# all, from goreleaser's fabricated v0.0.0. snapshot: version_template: "dev-{{ slice .FullCommit 0 12 }}" diff --git a/Dockerfile b/Dockerfile index 1dcc46a..6d4eb31 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,10 +20,10 @@ # golang:1.26.1-alpine, 2026-03-17 FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder -# Build tooling: make, plus a C toolchain because `go test -race` needs cgo. -# The sqlite driver is pure Go (modernc.org/sqlite), so no sqlite library or -# CLI is required. -RUN apk add --no-cache make build-base +# Build tooling: make, plus a C toolchain because `go test -race` needs cgo, +# and git, which derives the version below. The sqlite driver is pure Go +# (modernc.org/sqlite), so no sqlite library or CLI is required. +RUN apk add --no-cache make build-base git WORKDIR /src @@ -47,48 +47,44 @@ COPY . . # unreferenced-ARG handling staying as it is. It also puts the epoch in # the build log, where a reader can see the layer was keyed fresh. # -# The guard is what makes a build that omits --build-arg fail instead of -# lie. An unset ARG is an empty string, and an empty string is a -# perfectly stable cache key: without the guard the first such build -# runs the checks and every one after it on an unchanged tree replays -# these layers from cache, executes nothing, and still exits 0. Failed -# steps are never cached, so the guard fails on EVERY invocation rather -# than once -- a bare `docker build .` is a loud error, not a quiet -# green. Do not give CHECK_EPOCH a default value; a default would -# satisfy the guard with a constant and restore the hole. +# A build that passes no CHECK_EPOCH, such as a plain `docker build .`, +# keys these layers on the empty string, so rebuilding an unchanged +# checkout replays them from cache and runs nothing. Only the scripts' +# builds mean the checks executed. # # Everything above this line (apk, go.mod, `go mod download`) is # deliberately outside the busted range and keeps caching. ARG CHECK_EPOCH -RUN [ -n "$CHECK_EPOCH" ] || exit 1 RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check RUN echo "check epoch: ${CHECK_EPOCH}" && make test -# Version, commit and build date are computed on the host by -# script/docker and script/cibuild (where .git exists) and passed in as -# build args. The build context excludes .git (see .dockerignore), so -# the build cannot derive them itself: it used to try, with `git -# rev-parse` inside this stage, and always got "unknown". VERSION comes -# from script/version, the source of truth shared with the Makefile, so -# it carries the same tag / dev- / -dirty rules and a Docker image -# reports the same string a local build of the same tree would. -# -# The defaults are the fallback for a bare `docker build .` that passes -# none of them: an unset arg would otherwise stamp an empty string and -# produce an image that cannot report its own version, commit or date. -# They match what an out-of-git build reports elsewhere. +# Version, commit and build date: the build args when given (script/docker +# and script/cibuild pass the ones they compute on the host), otherwise +# derived from the .git in the build context. The version is then `git +# describe --tags --always`: the tag on a tagged commit, tag-N-gHASH after +# one, the short commit when no tag is reachable. A context that carries +# .git and still yields no version fails the build; one without .git, as +# from a source tarball, stamps "dev" and an "unknown" commit and date. # # These ARGs sit here, after the checks, rather than at the top of the # stage: every commit changes their values, and a value change # invalidates all layers below the ARG. Declared up top they would bust # `go mod download`; here they only rekey this build layer, which the # COPY of the sources above already rebuilds on any change anyway. -ARG VERSION=dev -ARG COMMIT=unknown -ARG COMMIT_DATE=unknown +ARG VERSION +ARG COMMIT +ARG COMMIT_DATE # Build (pure Go, no CGO required since we use modernc.org/sqlite) -RUN CGO_ENABLED=0 go build -ldflags "-X 'sneak.berlin/go/vaultik/internal/globals.Version=${VERSION}' -X 'sneak.berlin/go/vaultik/internal/globals.Commit=${COMMIT}' -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=${COMMIT_DATE}'" -o /vaultik ./cmd/vaultik +RUN version="${VERSION:-$(git describe --tags --always || echo dev)}"; \ + if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \ + [ "$version" = unknown ]; }; then \ + echo "the build context carries .git but yields no version" >&2; \ + exit 1; \ + fi; \ + commit="${COMMIT:-$(git rev-parse HEAD || echo unknown)}"; \ + commit_date="${COMMIT_DATE:-$(git show -s --format=%cs HEAD || echo unknown)}"; \ + CGO_ENABLED=0 go build -ldflags "-X 'sneak.berlin/go/vaultik/internal/globals.Version=${version}' -X 'sneak.berlin/go/vaultik/internal/globals.Commit=${commit}' -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=${commit_date}'" -o /vaultik ./cmd/vaultik # Runtime stage # alpine:3.21, 2026-02-25 diff --git a/Makefile b/Makefile index 17f714a..d0e60c2 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: all bootstrap setup check test lint lint-fix fmt fmt-check build clean deps test-coverage local install release release-snapshot docker hooks -# Version number, derived from git by script/version -- the tag when -# HEAD is on one, otherwise dev-. This used to be a hardcoded +# Version number, derived from git by script/version (`git describe +# --tags --always --dirty`). This used to be a hardcoded # constant, which meant every local build claimed to be a release that # had never been tagged. VERSION := $(shell script/version) diff --git a/README.md b/README.md index 8006bd1..b65aa34 100644 --- a/README.md +++ b/README.md @@ -770,8 +770,9 @@ them. We provide: * `script/projectname` — print the project name (used for the Docker image tag) * `script/version` — print the version string to bake into the binary. - The `Makefile`'s `LDFLAGS` call this; it is the single source of truth - for the version. See [releasing](#releasing) for the rules. + The `Makefile`'s `LDFLAGS` call this, and `script/docker` and + `script/cibuild` pass its output to the image build. See + [releasing](#releasing) for the rules. * `script/install-goreleaser` — install the pinned `goreleaser` into `.tool/bin` from a sha256-verified release archive. Idempotent, and called by `script/bootstrap`; the release workflow calls it directly @@ -863,16 +864,18 @@ them. We provide: module layers sit above the `ARG` and still cache, so a build is not cold. - A build that supplies no `CHECK_EPOCH` — a bare `docker build .` or - `docker build -f Dockerfile.lint .` — fails rather than lying. An - unset `ARG` is an empty string and an empty string is a stable cache - key, so without a guard such a build would serve every check layer - from cache, execute nothing, and still exit 0. Each file therefore - asserts the value is non-empty before running anything, and because - failed steps are never cached that assertion fires on every - invocation rather than once. Use `script/lint`, `script/docker` or - `script/cibuild`, which pass the arg; a bare `docker build` is a loud - error. + A `docker build -f Dockerfile.lint .` that supplies no `CHECK_EPOCH` + fails rather than lying. An unset `ARG` is an empty string and an + empty string is a stable cache key, so without a guard such a build + would serve the lint layer from cache, execute nothing, and still exit + 0. `Dockerfile.lint` therefore asserts the value is non-empty before + running anything, and because failed steps are never cached that + assertion fires on every invocation rather than once. The product + `Dockerfile` has no such guard, because a plain `docker build .` must + succeed: without `CHECK_EPOCH`, rebuilding an unchanged checkout + replays its check layers from cache. Use `script/lint`, + `script/docker` or `script/cibuild`, which pass the arg, when the + checks must run. * `script/precommit` — pre-commit gate: `go mod tidy` + `go fmt` (must not change files), then `script/check` * `script/install-precommit` — install the git pre-commit hook that @@ -883,24 +886,31 @@ them. We provide: ### version numbers The version a binary reports comes from git, not from a constant in a -file. `script/version` decides it, and everything that stamps a binary -agrees with it: +file. It is `git describe --tags --always --dirty`, which +`script/version` runs for the `Makefile`, `script/docker` and +`script/cibuild`: -* `HEAD` is exactly on a tag → that tag with a leading `v` stripped, so - the tag `v1.0.0` produces `vaultik 1.0.0`, matching the archive name - `vaultik_1.0.0_linux_amd64.tar.gz`. `goreleaser` strips the prefix the - same way. -* anything else → `dev-<12 chars of the commit sha>`. -* either, with uncommitted changes to tracked files → a `-dirty` +* `HEAD` is exactly on a tag → that tag, such as `v1.0.0`. +* a commit after a tag → `--g`. +* no tag reachable → the short commit sha. +* any of these, with uncommitted changes to tracked files → a `-dirty` suffix, because a modified checkout of a tag is not that tag. -A build that is not a release never names itself like one. `vaultik -version` says so in as many words on a development build, and -`goreleaser --snapshot` stamps the same `dev-` string rather than -inventing the next patch number. If `script/version` cannot be run at -all, `make` stops with an error instead of building an unversioned -binary, and a binary that somehow carries an empty version string still -reports itself as a development build. +A `docker build .` of a clone, with no build arguments, runs the same +`git describe` (without `--dirty`) on the `.git` in its build context, +so it stamps the same value for a clean commit; the build fails if the +context carries `.git` and no version comes out. A binary built without +git metadata reports `dev`. + +`goreleaser` stamps a release binary with the tag minus its leading +`v`, so the tag `v1.0.0` produces `vaultik 1.0.0`, matching the archive +name `vaultik_1.0.0_linux_amd64.tar.gz`. `goreleaser --snapshot` stamps +`dev-<12 chars of the commit sha>` rather than inventing the next patch +number. `vaultik version` calls `dev` and `dev-` development +builds. If `script/version` cannot be run at all, `make` stops with an +error instead of building an unversioned binary, and a binary that +somehow carries an empty version string still reports itself as a +development build. ### cutting a release diff --git a/cmd/vaultik/dockerversion_test.go b/cmd/vaultik/dockerversion_test.go index f77d274..d1ee2df 100644 --- a/cmd/vaultik/dockerversion_test.go +++ b/cmd/vaultik/dockerversion_test.go @@ -11,9 +11,10 @@ import ( // This file guards the version stamping of the product image (issue // #75). The failure it protects against is silent: the image still // builds and runs, but `vaultik version` inside it reports "commit: -// unknown", so an operator cannot tell which source produced a given -// backup. .dockerignore excludes .git, so the build cannot derive the -// commit itself; the values must be computed on the host and passed in. +// unknown" or a version of "dev", so an operator cannot tell which +// source produced a given backup. The build takes the values as build +// args, which script/docker computes on the host, and otherwise derives +// them from the .git in its context. // // These are parses of the committed files, for the same reason the lint // guards next door are: shelling out to docker would nest a build @@ -32,35 +33,41 @@ func versionArgs() []string { } // TestProductDockerfileTakesVersionAsBuildArgs fails unless the build -// declares each version arg and stamps it into the binary by ldflag -// reference, rather than computing it in the container. +// declares each version arg, with no default, and stamps it into the +// binary whenever it is given, ahead of the value derived in the +// container. func TestProductDockerfileTakesVersionAsBuildArgs(t *testing.T) { t.Parallel() found := instructions(t, productDockerfile) for _, arg := range versionArgs() { - require.GreaterOrEqual(t, indexOf(found, "ARG "+arg), 0, - "%s must declare `ARG %s` so the host can pass it in", - productDockerfile, arg) + require.Contains(t, found, "ARG "+arg, + "%s must declare `ARG %s`, with no default, so the host can"+ + " pass it in", productDockerfile, arg) assertLdflagReferences(t, found, arg) } } -// TestProductDockerfileDoesNotDeriveVersionItself is the anti-regression -// for the original defect: the container ran `git rev-parse`, but .git -// is not in the build context, so it always resolved to "unknown". No -// git command may reach into a build that cannot see the history. -func TestProductDockerfileDoesNotDeriveVersionItself(t *testing.T) { +// TestProductDockerfileDerivesVersionFromGit fails unless a build given +// no VERSION, such as a plain `docker build .` of a clone, takes it from +// `git describe` of the .git in its context, and fails rather than +// stamp "dev" when that .git yields no version. +func TestProductDockerfileDerivesVersionFromGit(t *testing.T) { t.Parallel() - text := instructionText(readRepoFile(t, productDockerfile)) + found := instructions(t, productDockerfile) - assert.NotContains(t, text, "git ", - "%s must not run git: .git is excluded from the build context, so"+ - " any value it derives is wrong. Pass version, commit and date"+ - " in as build args instead.", productDockerfile) + buildAt := indexContaining(found, "go build") + require.GreaterOrEqual(t, buildAt, 0, "%s must build", productDockerfile) + + assert.Contains(t, found[buildAt], "git describe --tags --always", + "%s must derive the version from git when no VERSION is given", + productDockerfile) + assert.Contains(t, found[buildAt], "[ -e .git ]", + "%s must fail when the context carries .git but yields no version", + productDockerfile) } // TestDockerScriptComputesVersionOnTheHost fails unless script/docker @@ -78,25 +85,25 @@ func TestDockerScriptComputesVersionOnTheHost(t *testing.T) { } assert.Contains(t, script, "/version", - "%s must take VERSION from script/version, the source of truth"+ - " shared with the Makefile", dockerScript) + "%s must take VERSION from script/version, as the Makefile does", + dockerScript) } -// assertLdflagReferences fails unless some build instruction stamps the -// named variable from the ARG (a ${arg} reference), not from a value -// computed inside the container. +// assertLdflagReferences fails unless the build instruction uses the +// named ARG whenever it is given (a ${arg:- reference), so a value +// passed in is not overridden by one derived inside the container. func assertLdflagReferences(t *testing.T, found []string, arg string) { t.Helper() for _, instruction := range found { if strings.HasPrefix(instruction, "RUN ") && strings.Contains(instruction, "go build") && - strings.Contains(instruction, "${"+arg+"}") { + strings.Contains(instruction, "${"+arg+":-") { return } } assert.Fail(t, "version arg is declared but never stamped", - "the go build in %s must reference ${%s} in its ldflags, or the"+ - " arg is passed and discarded", productDockerfile, arg) + "the go build in %s must use ${%s:-...}, or the arg is passed and"+ + " discarded", productDockerfile, arg) } diff --git a/cmd/vaultik/lintdocker_test.go b/cmd/vaultik/lintdocker_test.go index 07e8452..fa620ce 100644 --- a/cmd/vaultik/lintdocker_test.go +++ b/cmd/vaultik/lintdocker_test.go @@ -152,20 +152,21 @@ func TestLintDockerfileVerifiesTheLinterConfig(t *testing.T) { assertEpochExpandedInto(t, found, verify) } -// TestProductDockerfileCannotBeCachedGreen holds the same line for the -// checks that remain in the product image build. -func TestProductDockerfileCannotBeCachedGreen(t *testing.T) { +// TestProductDockerfileKeysChecksOnTheEpoch holds the same line for the +// checks that remain in the product image build, but without the guard: +// a plain `docker build .` with no build arguments must succeed. +func TestProductDockerfileKeysChecksOnTheEpoch(t *testing.T) { t.Parallel() found := instructions(t, productDockerfile) argAt := indexOf(found, checkEpochARG) require.GreaterOrEqual(t, argAt, 0, - "%s must declare `%s` with no default value", - productDockerfile, checkEpochARG) + "%s must declare `%s`", productDockerfile, checkEpochARG) - assert.GreaterOrEqual(t, indexOf(found, checkEpochGuard), argAt, - "%s must guard against an empty CHECK_EPOCH", productDockerfile) + assert.Equal(t, -1, indexOf(found, checkEpochGuard), + "%s must not refuse an empty CHECK_EPOCH: a plain `docker build .`"+ + " must succeed", productDockerfile) assertEpochExpandedInto(t, found[argAt:], "make fmt-check") assertEpochExpandedInto(t, found[argAt:], "make test") diff --git a/internal/globals/globals.go b/internal/globals/globals.go index 09233f9..804f27a 100644 --- a/internal/globals/globals.go +++ b/internal/globals/globals.go @@ -10,12 +10,11 @@ import ( // Appname is the application name, populated from main(). var Appname = "vaultik" //nolint:gochecknoglobals // set via -ldflags at build time -// DevVersion is the version a binary reports when it was not built -// from a tagged commit. script/version emits either this exact string -// (outside a git checkout) or this string followed by "-" and the -// commit it was built from, and goreleaser's snapshot template matches -// that shape. It is deliberately not a number: a build that is not a -// release must not name itself like one. +// DevVersion is the version a binary reports when it was built without +// git metadata: script/version emits it outside a git checkout, and an +// unstamped `go build` keeps it. goreleaser's snapshot template stamps +// it followed by "-" and the commit it was built from. It is +// deliberately not a number. const DevVersion = "dev" // Version is the application version, populated from main(). @@ -60,9 +59,10 @@ func New() (*Globals, error) { } // IsDevVersion reports whether v names a development build rather than -// a release. Both "dev" and "dev-" (and its "-dirty" variant) -// count: a caller that compares against "dev" exactly would treat every -// commit-stamped development build as a release. +// a release. Both "dev" and goreleaser's snapshot "dev-" count. A +// make or docker build of an untagged commit reports `git describe` +// output instead (the short commit, or tag-N-gHASH), which this does +// not recognise. // // The empty string counts too. Nothing that knows its version reports // no version, so an empty Version means the stamping failed, and the diff --git a/internal/globals/globals_test.go b/internal/globals/globals_test.go index e0b7c2e..e6bd76e 100644 --- a/internal/globals/globals_test.go +++ b/internal/globals/globals_test.go @@ -34,9 +34,9 @@ func TestGlobalsNew(t *testing.T) { } // TestIsDevVersion covers the boundary that matters: everything -// script/version and goreleaser's snapshot template can emit for an -// untagged build must be recognised as a development build, and a real -// tag must not be. A plain equality check against "dev" used to decide +// goreleaser's snapshot template, and script/version outside a git +// checkout, can emit must be recognised as a development build, and a +// real tag must not be. A plain equality check against "dev" used to decide // this, which classified every commit-stamped dev build as a release. func TestIsDevVersion(t *testing.T) { t.Parallel() @@ -49,8 +49,8 @@ func TestIsDevVersion(t *testing.T) { {"dev", true}, {"dev-b6e4a218a39e", true}, {"dev-b6e4a218a39e-dirty", true}, - // What a tagged build produces (script/version strips the - // leading "v", matching goreleaser's .Version). + // What a tagged build produces (goreleaser's .Version strips + // the leading "v"; script/version keeps it). {"1.0.0", false}, {"0.1.0", false}, {"1.0.0-rc.1", false}, diff --git a/script/cibuild b/script/cibuild index 339ee49..032fae6 100755 --- a/script/cibuild +++ b/script/cibuild @@ -24,9 +24,11 @@ main() { # value is what forces those layers to re-run: without it an # unchanged tree replays them from cache, the checks never execute, # and the build still exits 0. Each ARG sits immediately above the - # check RUNs, so dependency and module layers still cache. Both - # Dockerfiles also refuse to build at all when CHECK_EPOCH is empty, - # so a missing value fails loudly here rather than passing quietly. + # check RUNs, so dependency and module layers still cache. + # Dockerfile.lint also refuses to build at all when CHECK_EPOCH is + # empty, so a missing value fails its build loudly rather than passing + # quietly; the product Dockerfile does not, because a plain `docker + # build .` must succeed. # # The value must be unique per invocation, not per second. `date +%s` # is second-granular, so two concurrent invocations in the same @@ -57,12 +59,10 @@ main() { --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint . # Version, commit and build date are computed here on the host, the - # same way script/docker does, and passed into the product build so - # the CI-built image reports its real source. The build context - # excludes .git (see .dockerignore), so the build cannot derive them - # itself; without these it would stamp the Dockerfile's dev/unknown - # fallbacks. VERSION comes from script/version, the source of truth - # shared with the Makefile. + # same way script/docker does, and passed into the product build, + # where they take precedence over what the build would derive from + # the .git in its context. VERSION comes from script/version, as in + # the Makefile. version="$("$ROOT/script/version")" commit="$(git rev-parse HEAD 2>/dev/null || echo unknown)" commit_date="$(git show -s --format=%cs HEAD 2>/dev/null || echo unknown)" diff --git a/script/docker b/script/docker index b7f29bd..d685746 100755 --- a/script/docker +++ b/script/docker @@ -1,7 +1,8 @@ #!/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. Unlike the canonical copy in +# sneak/prompts, it passes a fresh CHECK_EPOCH instead of --no-cache, and +# COMMIT and COMMIT_DATE as well as VERSION. # # This builds the PRODUCT image only, and the product Dockerfile has no # lint stage: linting lives in Dockerfile.lint and is run by @@ -21,19 +22,15 @@ main() { # comments there. This script is not the CI gate, but a local build # is almost always warm, so without this it would report a green the # tree had not earned and the two entrypoints would disagree about - # whether the tree is clean. The Dockerfile now refuses to build - # without a non-empty value, so this is required, not optional. + # whether the tree is clean. epoch="$(date +%s%N)$$" - # Version, commit and build date are computed here on the host, - # where .git exists, and passed into the build. The build context - # excludes .git (see .dockerignore), so the container cannot derive - # them itself -- it used to try and always got "unknown", giving - # every image a "commit: unknown" it could not be traced from. - # VERSION comes from script/version, the source of truth shared with - # the Makefile, so a Docker build reports the same string (tag, - # dev-, or a -dirty variant) that a local build of the same - # tree would. + # Version, commit and build date are computed here on the host and + # passed into the build, where they take precedence over what the + # build would derive from the .git in its context. VERSION comes + # from script/version, as in the Makefile, so the image reports the + # same string, -dirty included, that a local build of the same tree + # would. version="$("$SCRIPT_DIR/version")" commit="$(git rev-parse HEAD 2>/dev/null || echo unknown)" commit_date="$(git show -s --format=%cs HEAD 2>/dev/null || echo unknown)" diff --git a/script/version b/script/version index 0df9b42..b4e0f4e 100755 --- a/script/version +++ b/script/version @@ -1,73 +1,28 @@ #!/bin/sh # script/version: output the version string to bake into the binary. -# Our own extension to scripts-to-rule-them-all, and the single source -# of truth for the version: the Makefile's LDFLAGS call this rather -# than carrying a hardcoded constant, which is what used to make every -# local build claim to be 1.0.0-rc.1 regardless of git state. +# Our own extension to scripts-to-rule-them-all. The Makefile's LDFLAGS +# call this rather than carrying a hardcoded constant, which is what used +# to make every local build claim to be 1.0.0-rc.1 regardless of git +# state. script/docker and script/cibuild pass its output to the image +# build, which otherwise runs the same `git describe` itself. # -# The rules, in order: +# The version is `git describe --tags --always --dirty`: the tag on a +# tagged commit, tag-N-gHASH on a commit after one, the short commit +# when no tag is reachable, each with a "-dirty" suffix when tracked +# files have uncommitted changes. Untracked files are ignored: a stray +# scratch file does not change what was compiled. Outside a git checkout +# (release tarball, `go install`), or in one with no commits, it is +# "dev". # -# HEAD is exactly on an annotated or lightweight tag -# -> that tag, with a leading "v" stripped -# anything else -# -> "dev-<12 chars of HEAD>" -# not a git checkout at all (release tarball, `go install`) -# -> "dev" -# -# Either of the first two gains a "-dirty" suffix when tracked files -# have uncommitted changes, because a modified checkout of v1.0.0 is -# not v1.0.0. Untracked files are ignored, matching `git describe -# --dirty`: a stray scratch file does not change what was compiled. -# -# The "v" is stripped so that a `make` build and a goreleaser build of -# the same tagged commit report the *same* string: goreleaser's -# {{ .Version }} is the tag without the prefix, and the release archive -# names are built from it. A tag named `v1.0.0` therefore produces -# `vaultik 1.0.0`, matching `vaultik_1.0.0_linux_amd64.tar.gz`. -# -# Nothing here ever invents a version number. An untagged build says so -# and names the commit it was built from; it does not round up to the -# nearest plausible release. +# Nothing here ever invents a version number. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" -# Length of the commit prefix in a dev version. Matches -# globals.ShortCommit, so `vaultik version` shows the same 12 chars in -# its version line and its commit line. -SHORT_LEN=12 - main() { cd "$ROOT" - - if ! git rev-parse --git-dir >/dev/null 2>&1; then - echo "dev" - return 0 - fi - - dirty="" - if [ -n "$(git status --porcelain --untracked-files=no 2>/dev/null)" ]; then - dirty="-dirty" - fi - - # --exact-match so a *descendant* of a tag is not reported as that - # tag. Plain `git describe --tags` would call a commit 40 patches - # past v1.0.0 "v1.0.0-40-gabc1234", and the leading token of that is - # a released version the build is not. - tag="$(git describe --tags --exact-match HEAD 2>/dev/null || true)" - if [ -n "$tag" ]; then - echo "${tag#v}${dirty}" - return 0 - fi - - sha="$(git rev-parse "--short=$SHORT_LEN" HEAD 2>/dev/null || true)" - if [ -z "$sha" ]; then - # A repo with no commits at all. - echo "dev" - return 0 - fi - - echo "dev-${sha}${dirty}" + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + echo "${version:-dev}" } main "$@"