Stamp the tag or short commit in a plain docker build (closes #211)
check / check (pull_request) Waiting to run

A plain `docker build .` stamped `dev`: `.dockerignore` left out `.git`
and the Dockerfile defaulted VERSION to `dev`. `.dockerignore` now
sends `.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.
`script/version` now prints `git describe --tags --always --dirty`, so
make, the scripts and a plain build agree. `vaultik version` treats
the short commit, tag-N-gHASH forms and any version ending in `-dirty`
as development builds, so they keep the development-build notice.

Model: opus-5-5
This commit is contained in:
2026-10-02 07:22:20 +00:00
parent 584444b619
commit ca07a78990
12 changed files with 241 additions and 196 deletions
+62 -2
View File
@@ -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 .gitea
*.md *.md
LICENSE LICENSE
@@ -7,4 +68,3 @@ dist
.tool .tool
coverage.out coverage.out
coverage.html coverage.html
.DS_Store
+1 -3
View File
@@ -47,9 +47,7 @@ checksum:
# A snapshot is not a release and must not name itself like one. The # A snapshot is not a release and must not name itself like one. The
# previous `{{ incpatch .Version }}-next` derived a plausible-looking # previous `{{ incpatch .Version }}-next` derived a plausible-looking
# release number from the last tag -- and with no tags in the repo at # 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 # all, from goreleaser's fabricated v0.0.0.
# string script/version produces for an untagged build, so a snapshot
# binary and a `make vaultik` binary of the same clean commit agree.
snapshot: snapshot:
version_template: "dev-{{ slice .FullCommit 0 12 }}" version_template: "dev-{{ slice .FullCommit 0 12 }}"
+27 -31
View File
@@ -20,10 +20,10 @@
# golang:1.26.1-alpine, 2026-03-17 # golang:1.26.1-alpine, 2026-03-17
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder
# Build tooling: make, plus a C toolchain because `go test -race` needs cgo. # 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 # and git, which derives the version below. The sqlite driver is pure Go
# CLI is required. # (modernc.org/sqlite), so no sqlite library or CLI is required.
RUN apk add --no-cache make build-base RUN apk add --no-cache make build-base git
WORKDIR /src WORKDIR /src
@@ -47,48 +47,44 @@ COPY . .
# unreferenced-ARG handling staying as it is. It also puts the epoch in # 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 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 # A build that passes no CHECK_EPOCH, such as a plain `docker build .`,
# lie. An unset ARG is an empty string, and an empty string is a # keys these layers on the empty string, so rebuilding an unchanged
# perfectly stable cache key: without the guard the first such build # checkout replays them from cache and runs nothing. Only the scripts'
# runs the checks and every one after it on an unchanged tree replays # builds mean the checks executed.
# 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.
# #
# Everything above this line (apk, go.mod, `go mod download`) is # Everything above this line (apk, go.mod, `go mod download`) is
# deliberately outside the busted range and keeps caching. # deliberately outside the busted range and keeps caching.
ARG CHECK_EPOCH 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 fmt-check
RUN echo "check epoch: ${CHECK_EPOCH}" && make test RUN echo "check epoch: ${CHECK_EPOCH}" && make test
# Version, commit and build date are computed on the host by # Version, commit and build date: the build args when given (script/docker
# script/docker and script/cibuild (where .git exists) and passed in as # and script/cibuild pass the ones they compute on the host), otherwise
# build args. The build context excludes .git (see .dockerignore), so # derived from the .git in the build context. The version is then `git
# the build cannot derive them itself: it used to try, with `git # describe --tags --always`: the tag on a tagged commit, tag-N-gHASH after
# rev-parse` inside this stage, and always got "unknown". VERSION comes # one, the short commit when no tag is reachable. A context that carries
# from script/version, the source of truth shared with the Makefile, so # .git and still yields no version fails the build; one without .git, as
# it carries the same tag / dev-<sha> / -dirty rules and a Docker image # from a source tarball, stamps "dev" and an "unknown" commit and date.
# 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.
# #
# These ARGs sit here, after the checks, rather than at the top of the # These ARGs sit here, after the checks, rather than at the top of the
# stage: every commit changes their values, and a value change # stage: every commit changes their values, and a value change
# invalidates all layers below the ARG. Declared up top they would bust # invalidates all layers below the ARG. Declared up top they would bust
# `go mod download`; here they only rekey this build layer, which the # `go mod download`; here they only rekey this build layer, which the
# COPY of the sources above already rebuilds on any change anyway. # COPY of the sources above already rebuilds on any change anyway.
ARG VERSION=dev ARG VERSION
ARG COMMIT=unknown ARG COMMIT
ARG COMMIT_DATE=unknown ARG COMMIT_DATE
# Build (pure Go, no CGO required since we use modernc.org/sqlite) # 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 # Runtime stage
# alpine:3.21, 2026-02-25 # alpine:3.21, 2026-02-25
+2 -2
View File
@@ -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 .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 # Version number, derived from git by script/version (`git describe
# HEAD is on one, otherwise dev-<sha>. This used to be a hardcoded # --tags --always --dirty`). This used to be a hardcoded
# constant, which meant every local build claimed to be a release that # constant, which meant every local build claimed to be a release that
# had never been tagged. # had never been tagged.
VERSION := $(shell script/version) VERSION := $(shell script/version)
+39 -27
View File
@@ -770,8 +770,9 @@ them. We provide:
* `script/projectname` — print the project name (used for the Docker * `script/projectname` — print the project name (used for the Docker
image tag) image tag)
* `script/version` — print the version string to bake into the binary. * `script/version` — print the version string to bake into the binary.
The `Makefile`'s `LDFLAGS` call this; it is the single source of truth The `Makefile`'s `LDFLAGS` call this, and `script/docker` and
for the version. See [releasing](#releasing) for the rules. `script/cibuild` pass its output to the image build. See
[releasing](#releasing) for the rules.
* `script/install-goreleaser` — install the pinned `goreleaser` into * `script/install-goreleaser` — install the pinned `goreleaser` into
`.tool/bin` from a sha256-verified release archive. Idempotent, and `.tool/bin` from a sha256-verified release archive. Idempotent, and
called by `script/bootstrap`; the release workflow calls it directly 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 module layers sit above the `ARG` and still cache, so a build is not
cold. cold.
A build that supplies no `CHECK_EPOCH` — a bare `docker build .` or A `docker build -f Dockerfile.lint .` that supplies no `CHECK_EPOCH`
`docker build -f Dockerfile.lint .` — fails rather than lying. An fails rather than lying. An unset `ARG` is an empty string and an
unset `ARG` is an empty string and an empty string is a stable cache empty string is a stable cache key, so without a guard such a build
key, so without a guard such a build would serve every check layer would serve the lint layer from cache, execute nothing, and still exit
from cache, execute nothing, and still exit 0. Each file therefore 0. `Dockerfile.lint` therefore asserts the value is non-empty before
asserts the value is non-empty before running anything, and because running anything, and because failed steps are never cached that
failed steps are never cached that assertion fires on every assertion fires on every invocation rather than once. The product
invocation rather than once. Use `script/lint`, `script/docker` or `Dockerfile` has no such guard, because a plain `docker build .` must
`script/cibuild`, which pass the arg; a bare `docker build` is a loud succeed: without `CHECK_EPOCH`, rebuilding an unchanged checkout
error. 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 * `script/precommit` — pre-commit gate: `go mod tidy` + `go fmt` (must
not change files), then `script/check` not change files), then `script/check`
* `script/install-precommit` — install the git pre-commit hook that * `script/install-precommit` — install the git pre-commit hook that
@@ -883,24 +886,33 @@ them. We provide:
### version numbers ### version numbers
The version a binary reports comes from git, not from a constant in a 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 file. It is `git describe --tags --always --dirty`, which
agrees with it: `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 * `HEAD` is exactly on a tag → that tag, such as `v1.0.0`.
the tag `v1.0.0` produces `vaultik 1.0.0`, matching the archive name * a commit after a tag → `<tag>-<N>-g<short sha>`.
`vaultik_1.0.0_linux_amd64.tar.gz`. `goreleaser` strips the prefix the * no tag reachable → the short commit sha.
same way. * any of these, with uncommitted changes to tracked files → a `-dirty`
* anything else → `dev-<12 chars of the commit sha>`.
* either, with uncommitted changes to tracked files → a `-dirty`
suffix, because a modified checkout of a tag is not that tag. 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 A `docker build .` of a clone, with no build arguments, runs the same
version` says so in as many words on a development build, and `git describe` (without `--dirty`) on the `.git` in its build context,
`goreleaser --snapshot` stamps the same `dev-<sha>` string rather than so it stamps the same value for a clean commit; the build fails if the
inventing the next patch number. If `script/version` cannot be run at context carries `.git` and no version comes out. A binary built without
all, `make` stops with an error instead of building an unversioned git metadata reports `dev`.
binary, and a binary that somehow carries an empty version string still
reports itself as a development build. `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 a build a development build when its
version is `dev`, `dev-<sha>`, the short commit sha or
`<tag>-<N>-g<short sha>`, with or without `-dirty`; only a plain tag,
such as `v1.0.0` or `1.0.0`, is a release. 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 ### cutting a release
+33 -26
View File
@@ -11,9 +11,10 @@ import (
// This file guards the version stamping of the product image (issue // This file guards the version stamping of the product image (issue
// #75). The failure it protects against is silent: the image still // #75). The failure it protects against is silent: the image still
// builds and runs, but `vaultik version` inside it reports "commit: // builds and runs, but `vaultik version` inside it reports "commit:
// unknown", so an operator cannot tell which source produced a given // unknown" or a version of "dev", so an operator cannot tell which
// backup. .dockerignore excludes .git, so the build cannot derive the // source produced a given backup. The build takes the values as build
// commit itself; the values must be computed on the host and passed in. // 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 // 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 // guards next door are: shelling out to docker would nest a build
@@ -32,35 +33,41 @@ func versionArgs() []string {
} }
// TestProductDockerfileTakesVersionAsBuildArgs fails unless the build // TestProductDockerfileTakesVersionAsBuildArgs fails unless the build
// declares each version arg and stamps it into the binary by ldflag // declares each version arg, with no default, and stamps it into the
// reference, rather than computing it in the container. // binary whenever it is given, ahead of the value derived in the
// container.
func TestProductDockerfileTakesVersionAsBuildArgs(t *testing.T) { func TestProductDockerfileTakesVersionAsBuildArgs(t *testing.T) {
t.Parallel() t.Parallel()
found := instructions(t, productDockerfile) found := instructions(t, productDockerfile)
for _, arg := range versionArgs() { for _, arg := range versionArgs() {
require.GreaterOrEqual(t, indexOf(found, "ARG "+arg), 0, require.Contains(t, found, "ARG "+arg,
"%s must declare `ARG %s` so the host can pass it in", "%s must declare `ARG %s`, with no default, so the host can"+
productDockerfile, arg) " pass it in", productDockerfile, arg)
assertLdflagReferences(t, found, arg) assertLdflagReferences(t, found, arg)
} }
} }
// TestProductDockerfileDoesNotDeriveVersionItself is the anti-regression // TestProductDockerfileDerivesVersionFromGit fails unless a build given
// for the original defect: the container ran `git rev-parse`, but .git // no VERSION, such as a plain `docker build .` of a clone, takes it from
// is not in the build context, so it always resolved to "unknown". No // `git describe` of the .git in its context, and fails rather than
// git command may reach into a build that cannot see the history. // stamp "dev" when that .git yields no version.
func TestProductDockerfileDoesNotDeriveVersionItself(t *testing.T) { func TestProductDockerfileDerivesVersionFromGit(t *testing.T) {
t.Parallel() t.Parallel()
text := instructionText(readRepoFile(t, productDockerfile)) found := instructions(t, productDockerfile)
assert.NotContains(t, text, "git ", buildAt := indexContaining(found, "go build")
"%s must not run git: .git is excluded from the build context, so"+ require.GreaterOrEqual(t, buildAt, 0, "%s must build", productDockerfile)
" any value it derives is wrong. Pass version, commit and date"+
" in as build args instead.", 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 // TestDockerScriptComputesVersionOnTheHost fails unless script/docker
@@ -78,25 +85,25 @@ func TestDockerScriptComputesVersionOnTheHost(t *testing.T) {
} }
assert.Contains(t, script, "/version", assert.Contains(t, script, "/version",
"%s must take VERSION from script/version, the source of truth"+ "%s must take VERSION from script/version, as the Makefile does",
" shared with the Makefile", dockerScript) dockerScript)
} }
// assertLdflagReferences fails unless some build instruction stamps the // assertLdflagReferences fails unless the build instruction uses the
// named variable from the ARG (a ${arg} reference), not from a value // named ARG whenever it is given (a ${arg:- reference), so a value
// computed inside the container. // passed in is not overridden by one derived inside the container.
func assertLdflagReferences(t *testing.T, found []string, arg string) { func assertLdflagReferences(t *testing.T, found []string, arg string) {
t.Helper() t.Helper()
for _, instruction := range found { for _, instruction := range found {
if strings.HasPrefix(instruction, "RUN ") && if strings.HasPrefix(instruction, "RUN ") &&
strings.Contains(instruction, "go build") && strings.Contains(instruction, "go build") &&
strings.Contains(instruction, "${"+arg+"}") { strings.Contains(instruction, "${"+arg+":-") {
return return
} }
} }
assert.Fail(t, "version arg is declared but never stamped", assert.Fail(t, "version arg is declared but never stamped",
"the go build in %s must reference ${%s} in its ldflags, or the"+ "the go build in %s must use ${%s:-...}, or the arg is passed and"+
" arg is passed and discarded", productDockerfile, arg) " discarded", productDockerfile, arg)
} }
+8 -7
View File
@@ -152,20 +152,21 @@ func TestLintDockerfileVerifiesTheLinterConfig(t *testing.T) {
assertEpochExpandedInto(t, found, verify) assertEpochExpandedInto(t, found, verify)
} }
// TestProductDockerfileCannotBeCachedGreen holds the same line for the // TestProductDockerfileKeysChecksOnTheEpoch holds the same line for the
// checks that remain in the product image build. // checks that remain in the product image build, but without the guard:
func TestProductDockerfileCannotBeCachedGreen(t *testing.T) { // a plain `docker build .` with no build arguments must succeed.
func TestProductDockerfileKeysChecksOnTheEpoch(t *testing.T) {
t.Parallel() t.Parallel()
found := instructions(t, productDockerfile) found := instructions(t, productDockerfile)
argAt := indexOf(found, checkEpochARG) argAt := indexOf(found, checkEpochARG)
require.GreaterOrEqual(t, argAt, 0, require.GreaterOrEqual(t, argAt, 0,
"%s must declare `%s` with no default value", "%s must declare `%s`", productDockerfile, checkEpochARG)
productDockerfile, checkEpochARG)
assert.GreaterOrEqual(t, indexOf(found, checkEpochGuard), argAt, assert.Equal(t, -1, indexOf(found, checkEpochGuard),
"%s must guard against an empty CHECK_EPOCH", productDockerfile) "%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 fmt-check")
assertEpochExpandedInto(t, found[argAt:], "make test") assertEpochExpandedInto(t, found[argAt:], "make test")
+18 -10
View File
@@ -3,6 +3,7 @@
package globals package globals
import ( import (
"regexp"
"strings" "strings"
"time" "time"
) )
@@ -10,12 +11,11 @@ import (
// Appname is the application name, populated from main(). // Appname is the application name, populated from main().
var Appname = "vaultik" //nolint:gochecknoglobals // set via -ldflags at build time var Appname = "vaultik" //nolint:gochecknoglobals // set via -ldflags at build time
// DevVersion is the version a binary reports when it was not built // DevVersion is the version a binary reports when it was built without
// from a tagged commit. script/version emits either this exact string // git metadata: script/version emits it outside a git checkout, and an
// (outside a git checkout) or this string followed by "-" and the // unstamped `go build` keeps it. goreleaser's snapshot template stamps
// commit it was built from, and goreleaser's snapshot template matches // it followed by "-" and the commit it was built from. It is
// that shape. It is deliberately not a number: a build that is not a // deliberately not a number.
// release must not name itself like one.
const DevVersion = "dev" const DevVersion = "dev"
// Version is the application version, populated from main(). // Version is the application version, populated from main().
@@ -60,9 +60,12 @@ func New() (*Globals, error) {
} }
// IsDevVersion reports whether v names a development build rather than // IsDevVersion reports whether v names a development build rather than
// a release. Both "dev" and "dev-<sha>" (and its "-dirty" variant) // a release. "dev" and goreleaser's snapshot "dev-<sha>" count, and so
// count: a caller that compares against "dev" exactly would treat every // does what `git describe --tags --always --dirty` gives a make or
// commit-stamped development build as a release. // docker build of an untagged commit: the bare short commit, or
// tag-N-gHASH on a commit after a tag. Any version ending in "-dirty"
// counts, a modified checkout of a tag ("v1.0.0-dirty") included.
// A plain tag such as "v1.0.0" or "1.0.0" is a release.
// //
// The empty string counts too. Nothing that knows its version reports // The empty string counts too. Nothing that knows its version reports
// no version, so an empty Version means the stamping failed, and the // no version, so an empty Version means the stamping failed, and the
@@ -71,7 +74,12 @@ func New() (*Globals, error) {
// case; this is the second line of defence, for a binary linked by // case; this is the second line of defence, for a binary linked by
// something other than the Makefile. // something other than the Makefile.
func IsDevVersion(v string) bool { func IsDevVersion(v string) bool {
return v == "" || v == DevVersion || strings.HasPrefix(v, DevVersion+"-") if v == "" || v == DevVersion || strings.HasPrefix(v, DevVersion+"-") ||
strings.HasSuffix(v, "-dirty") {
return true
}
return regexp.MustCompile(`^(.+-[0-9]+-g)?[0-9a-f]+$`).MatchString(v)
} }
// shortCommitLen is the number of commit-hash characters ShortCommit keeps. // shortCommitLen is the number of commit-hash characters ShortCommit keeps.
+16 -6
View File
@@ -34,10 +34,11 @@ func TestGlobalsNew(t *testing.T) {
} }
// TestIsDevVersion covers the boundary that matters: everything // TestIsDevVersion covers the boundary that matters: everything
// script/version and goreleaser's snapshot template can emit for an // script/version, a plain docker build and goreleaser's snapshot
// untagged build must be recognised as a development build, and a real // template can emit for an untagged build must be recognised as a
// tag must not be. A plain equality check against "dev" used to decide // development build, and a real tag must not be. A plain equality check
// this, which classified every commit-stamped dev build as a release. // against "dev" used to decide this, which classified every
// commit-stamped dev build as a release.
func TestIsDevVersion(t *testing.T) { func TestIsDevVersion(t *testing.T) {
t.Parallel() t.Parallel()
@@ -49,8 +50,17 @@ func TestIsDevVersion(t *testing.T) {
{"dev", true}, {"dev", true},
{"dev-b6e4a218a39e", true}, {"dev-b6e4a218a39e", true},
{"dev-b6e4a218a39e-dirty", true}, {"dev-b6e4a218a39e-dirty", true},
// What a tagged build produces (script/version strips the // What `git describe --tags --always --dirty` produces with no
// leading "v", matching goreleaser's .Version). // tag reachable, and on a commit after a tag.
{"877eb2f", true},
{"877eb2f-dirty", true},
{"v1.0.0-3-g877eb2f", true},
{"v1.0.0-3-g877eb2f-dirty", true},
{"1.0.0-rc.1-12-g877eb2f", true},
// A tagged commit with uncommitted changes is not that tag.
{"v1.0.0-dirty", true},
// What a tagged build produces (goreleaser's .Version strips
// the leading "v"; script/version keeps it).
{"1.0.0", false}, {"1.0.0", false},
{"0.1.0", false}, {"0.1.0", false},
{"1.0.0-rc.1", false}, {"1.0.0-rc.1", false},
+9 -9
View File
@@ -24,9 +24,11 @@ main() {
# value is what forces those layers to re-run: without it an # value is what forces those layers to re-run: without it an
# unchanged tree replays them from cache, the checks never execute, # unchanged tree replays them from cache, the checks never execute,
# and the build still exits 0. Each ARG sits immediately above the # and the build still exits 0. Each ARG sits immediately above the
# check RUNs, so dependency and module layers still cache. Both # check RUNs, so dependency and module layers still cache.
# Dockerfiles also refuse to build at all when CHECK_EPOCH is empty, # Dockerfile.lint also refuses to build at all when CHECK_EPOCH is
# so a missing value fails loudly here rather than passing quietly. # 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` # The value must be unique per invocation, not per second. `date +%s`
# is second-granular, so two concurrent invocations in the same # is second-granular, so two concurrent invocations in the same
@@ -57,12 +59,10 @@ main() {
--build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint . --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .
# Version, commit and build date are computed here on the host, the # Version, commit and build date are computed here on the host, the
# same way script/docker does, and passed into the product build so # same way script/docker does, and passed into the product build,
# the CI-built image reports its real source. The build context # where they take precedence over what the build would derive from
# excludes .git (see .dockerignore), so the build cannot derive them # the .git in its context. VERSION comes from script/version, as in
# itself; without these it would stamp the Dockerfile's dev/unknown # the Makefile.
# fallbacks. VERSION comes from script/version, the source of truth
# shared with the Makefile.
version="$("$ROOT/script/version")" version="$("$ROOT/script/version")"
commit="$(git rev-parse HEAD 2>/dev/null || echo unknown)" commit="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
commit_date="$(git show -s --format=%cs HEAD 2>/dev/null || echo unknown)" commit_date="$(git show -s --format=%cs HEAD 2>/dev/null || echo unknown)"
+10 -13
View File
@@ -1,7 +1,8 @@
#!/bin/sh #!/bin/sh
# script/docker: build the Docker image tagged with the project name. # script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname. # The tag comes from script/projectname. Unlike the canonical copy in
# Generic: needs no adaptation. # 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 # This builds the PRODUCT image only, and the product Dockerfile has no
# lint stage: linting lives in Dockerfile.lint and is run by # 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 # 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 # is almost always warm, so without this it would report a green the
# tree had not earned and the two entrypoints would disagree about # tree had not earned and the two entrypoints would disagree about
# whether the tree is clean. The Dockerfile now refuses to build # whether the tree is clean.
# without a non-empty value, so this is required, not optional.
epoch="$(date +%s%N)$$" epoch="$(date +%s%N)$$"
# Version, commit and build date are computed here on the host, # Version, commit and build date are computed here on the host and
# where .git exists, and passed into the build. The build context # passed into the build, where they take precedence over what the
# excludes .git (see .dockerignore), so the container cannot derive # build would derive from the .git in its context. VERSION comes
# them itself -- it used to try and always got "unknown", giving # from script/version, as in the Makefile, so the image reports the
# every image a "commit: unknown" it could not be traced from. # same string, -dirty included, that a local build of the same tree
# VERSION comes from script/version, the source of truth shared with # would.
# the Makefile, so a Docker build reports the same string (tag,
# dev-<sha>, or a -dirty variant) that a local build of the same
# tree would.
version="$("$SCRIPT_DIR/version")" version="$("$SCRIPT_DIR/version")"
commit="$(git rev-parse HEAD 2>/dev/null || echo unknown)" commit="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
commit_date="$(git show -s --format=%cs HEAD 2>/dev/null || echo unknown)" commit_date="$(git show -s --format=%cs HEAD 2>/dev/null || echo unknown)"
+16 -60
View File
@@ -1,73 +1,29 @@
#!/bin/sh #!/bin/sh
# script/version: output the version string to bake into the binary. # script/version: output the version string to bake into the binary.
# Our own extension to scripts-to-rule-them-all, and the single source # Our own extension to scripts-to-rule-them-all. The Makefile's LDFLAGS
# of truth for the version: the Makefile's LDFLAGS call this rather # call this rather than carrying a hardcoded constant, which is what used
# than carrying a hardcoded constant, which is what used to make every # to make every local build claim to be 1.0.0-rc.1 regardless of git
# local build claim to be 1.0.0-rc.1 regardless of git state. # state. script/docker and script/cibuild pass its output to the image
# build; given no version, the image build runs `git describe --tags
# --always` itself, without `--dirty`.
# #
# 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 # Nothing here ever invents a version number.
# -> 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.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" 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() { main() {
cd "$ROOT" cd "$ROOT"
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
if ! git rev-parse --git-dir >/dev/null 2>&1; then echo "${version:-dev}"
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}"
} }
main "$@" main "$@"