Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 8076a183e0 List only after-1.0 work in the README roadmap (closes #208)
check / check (pull_request) Successful in 5m0s
The README roadmap and the TODO.md Next Step still described finished
1.0 work as remaining. The roadmap now lists only work planned after
1.0. The security item says the encryption and blob-generation code was
reviewed before 1.0, that every bug the review found was fixed, and
that the risks it accepted are listed in Accepted Risks; an outside
audit stays as after-1.0 work rather than a blocker. The error-condition
item is gone because every failure case it listed now has a
fault-injection test. Daemon mode, which the owner put after 1.0, is
added to the roadmap. TODO.md says the 1.0 work is complete on next and
that merging to main and tagging are the owner's; Future Steps points
to the roadmap.

Judgement call: dropped the human-readable size flags item; no command
flag takes a raw-integer size.

Model: opus-5-5
2026-10-01 18:59:24 +00:00
12 changed files with 196 additions and 241 deletions
+2 -62
View File
@@ -1,65 +1,4 @@
# .dockerignore does NOT use .gitignore semantics. Docker matches with .git
# 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
@@ -68,3 +7,4 @@ dist
.tool .tool
coverage.out coverage.out
coverage.html coverage.html
.DS_Store
+3 -1
View File
@@ -47,7 +47,9 @@ 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. # 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.
snapshot: snapshot:
version_template: "dev-{{ slice .FullCommit 0 12 }}" version_template: "dev-{{ slice .FullCommit 0 12 }}"
+31 -27
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.
# and git, which derives the version below. The sqlite driver is pure Go # The sqlite driver is pure Go (modernc.org/sqlite), so no sqlite library or
# (modernc.org/sqlite), so no sqlite library or CLI is required. # CLI is required.
RUN apk add --no-cache make build-base git RUN apk add --no-cache make build-base
WORKDIR /src WORKDIR /src
@@ -47,44 +47,48 @@ 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.
# #
# A build that passes no CHECK_EPOCH, such as a plain `docker build .`, # The guard is what makes a build that omits --build-arg fail instead of
# keys these layers on the empty string, so rebuilding an unchanged # lie. An unset ARG is an empty string, and an empty string is a
# checkout replays them from cache and runs nothing. Only the scripts' # perfectly stable cache key: without the guard the first such build
# builds mean the checks executed. # 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.
# #
# 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: the build args when given (script/docker # Version, commit and build date are computed on the host by
# and script/cibuild pass the ones they compute on the host), otherwise # script/docker and script/cibuild (where .git exists) and passed in as
# derived from the .git in the build context. The version is then `git # build args. The build context excludes .git (see .dockerignore), so
# describe --tags --always`: the tag on a tagged commit, tag-N-gHASH after # the build cannot derive them itself: it used to try, with `git
# one, the short commit when no tag is reachable. A context that carries # rev-parse` inside this stage, and always got "unknown". VERSION comes
# .git and still yields no version fails the build; one without .git, as # from script/version, the source of truth shared with the Makefile, so
# from a source tarball, stamps "dev" and an "unknown" commit and date. # it carries the same tag / dev-<sha> / -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.
# #
# 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 ARG VERSION=dev
ARG COMMIT ARG COMMIT=unknown
ARG COMMIT_DATE ARG COMMIT_DATE=unknown
# 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 version="${VERSION:-$(git describe --tags --always || echo dev)}"; \ 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
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 (`git describe # Version number, derived from git by script/version -- the tag when
# --tags --always --dirty`). This used to be a hardcoded # HEAD is on one, otherwise dev-<sha>. 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)
+27 -39
View File
@@ -770,9 +770,8 @@ 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, and `script/docker` and The `Makefile`'s `LDFLAGS` call this; it is the single source of truth
`script/cibuild` pass its output to the image build. See for the version. See [releasing](#releasing) for the rules.
[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
@@ -864,18 +863,16 @@ 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 `docker build -f Dockerfile.lint .` that supplies no `CHECK_EPOCH` A build that supplies no `CHECK_EPOCH` — a bare `docker build .` or
fails rather than lying. An unset `ARG` is an empty string and an `docker build -f Dockerfile.lint .` — fails rather than lying. An
empty string is a stable cache key, so without a guard such a build unset `ARG` is an empty string and an empty string is a stable cache
would serve the lint layer from cache, execute nothing, and still exit key, so without a guard such a build would serve every check layer
0. `Dockerfile.lint` therefore asserts the value is non-empty before from cache, execute nothing, and still exit 0. Each file therefore
running anything, and because failed steps are never cached that asserts the value is non-empty before running anything, and because
assertion fires on every invocation rather than once. The product failed steps are never cached that assertion fires on every
`Dockerfile` has no such guard, because a plain `docker build .` must invocation rather than once. Use `script/lint`, `script/docker` or
succeed: without `CHECK_EPOCH`, rebuilding an unchanged checkout `script/cibuild`, which pass the arg; a bare `docker build` is a loud
replays its check layers from cache. Use `script/lint`, error.
`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
@@ -886,33 +883,24 @@ 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. It is `git describe --tags --always --dirty`, which file. `script/version` decides it, and everything that stamps a binary
`script/version` runs for the `Makefile`, `script/docker` and agrees with it:
`script/cibuild`:
* `HEAD` is exactly on a tag → that tag, such as `v1.0.0`. * `HEAD` is exactly on a tag → that tag with a leading `v` stripped, so
* a commit after a tag → `<tag>-<N>-g<short sha>`. the tag `v1.0.0` produces `vaultik 1.0.0`, matching the archive name
* no tag reachable → the short commit sha. `vaultik_1.0.0_linux_amd64.tar.gz`. `goreleaser` strips the prefix the
* any of these, with uncommitted changes to tracked files → a `-dirty` same way.
* 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 `docker build .` of a clone, with no build arguments, runs the same A build that is not a release never names itself like one. `vaultik
`git describe` (without `--dirty`) on the `.git` in its build context, version` says so in as many words on a development build, and
so it stamps the same value for a clean commit; the build fails if the `goreleaser --snapshot` stamps the same `dev-<sha>` string rather than
context carries `.git` and no version comes out. A binary built without inventing the next patch number. If `script/version` cannot be run at
git metadata reports `dev`. all, `make` stops with an error instead of building an unversioned
binary, and a binary that somehow carries an empty version string still
`goreleaser` stamps a release binary with the tag minus its leading reports itself as a development build.
`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
+26 -33
View File
@@ -11,10 +11,9 @@ 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" or a version of "dev", so an operator cannot tell which // unknown", so an operator cannot tell which source produced a given
// source produced a given backup. The build takes the values as build // backup. .dockerignore excludes .git, so the build cannot derive the
// args, which script/docker computes on the host, and otherwise derives // commit itself; the values must be computed on the host and passed in.
// 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
@@ -33,41 +32,35 @@ func versionArgs() []string {
} }
// TestProductDockerfileTakesVersionAsBuildArgs fails unless the build // TestProductDockerfileTakesVersionAsBuildArgs fails unless the build
// declares each version arg, with no default, and stamps it into the // declares each version arg and stamps it into the binary by ldflag
// binary whenever it is given, ahead of the value derived in the // reference, rather than computing it in the container.
// 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.Contains(t, found, "ARG "+arg, require.GreaterOrEqual(t, indexOf(found, "ARG "+arg), 0,
"%s must declare `ARG %s`, with no default, so the host can"+ "%s must declare `ARG %s` so the host can pass it in",
" pass it in", productDockerfile, arg) productDockerfile, arg)
assertLdflagReferences(t, found, arg) assertLdflagReferences(t, found, arg)
} }
} }
// TestProductDockerfileDerivesVersionFromGit fails unless a build given // TestProductDockerfileDoesNotDeriveVersionItself is the anti-regression
// no VERSION, such as a plain `docker build .` of a clone, takes it from // for the original defect: the container ran `git rev-parse`, but .git
// `git describe` of the .git in its context, and fails rather than // is not in the build context, so it always resolved to "unknown". No
// stamp "dev" when that .git yields no version. // git command may reach into a build that cannot see the history.
func TestProductDockerfileDerivesVersionFromGit(t *testing.T) { func TestProductDockerfileDoesNotDeriveVersionItself(t *testing.T) {
t.Parallel() t.Parallel()
found := instructions(t, productDockerfile) text := instructionText(readRepoFile(t, productDockerfile))
buildAt := indexContaining(found, "go build") assert.NotContains(t, text, "git ",
require.GreaterOrEqual(t, buildAt, 0, "%s must build", productDockerfile) "%s must not run git: .git is excluded from the build context, so"+
" any value it derives is wrong. Pass version, commit and date"+
assert.Contains(t, found[buildAt], "git describe --tags --always", " in as build args instead.", productDockerfile)
"%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
@@ -85,25 +78,25 @@ func TestDockerScriptComputesVersionOnTheHost(t *testing.T) {
} }
assert.Contains(t, script, "/version", assert.Contains(t, script, "/version",
"%s must take VERSION from script/version, as the Makefile does", "%s must take VERSION from script/version, the source of truth"+
dockerScript) " shared with the Makefile", dockerScript)
} }
// assertLdflagReferences fails unless the build instruction uses the // assertLdflagReferences fails unless some build instruction stamps the
// named ARG whenever it is given (a ${arg:- reference), so a value // named variable from the ARG (a ${arg} reference), not from a value
// passed in is not overridden by one derived inside the container. // computed 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 use ${%s:-...}, or the arg is passed and"+ "the go build in %s must reference ${%s} in its ldflags, or the"+
" discarded", productDockerfile, arg) " arg is passed and discarded", productDockerfile, arg)
} }
+7 -8
View File
@@ -152,21 +152,20 @@ func TestLintDockerfileVerifiesTheLinterConfig(t *testing.T) {
assertEpochExpandedInto(t, found, verify) assertEpochExpandedInto(t, found, verify)
} }
// TestProductDockerfileKeysChecksOnTheEpoch holds the same line for the // TestProductDockerfileCannotBeCachedGreen holds the same line for the
// checks that remain in the product image build, but without the guard: // checks that remain in the product image build.
// a plain `docker build .` with no build arguments must succeed. func TestProductDockerfileCannotBeCachedGreen(t *testing.T) {
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`", productDockerfile, checkEpochARG) "%s must declare `%s` with no default value",
productDockerfile, checkEpochARG)
assert.Equal(t, -1, indexOf(found, checkEpochGuard), assert.GreaterOrEqual(t, indexOf(found, checkEpochGuard), argAt,
"%s must not refuse an empty CHECK_EPOCH: a plain `docker build .`"+ "%s must guard against an empty CHECK_EPOCH", productDockerfile)
" 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")
+10 -18
View File
@@ -3,7 +3,6 @@
package globals package globals
import ( import (
"regexp"
"strings" "strings"
"time" "time"
) )
@@ -11,11 +10,12 @@ 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 built without // DevVersion is the version a binary reports when it was not built
// git metadata: script/version emits it outside a git checkout, and an // from a tagged commit. script/version emits either this exact string
// unstamped `go build` keeps it. goreleaser's snapshot template stamps // (outside a git checkout) or this string followed by "-" and the
// it followed by "-" and the commit it was built from. It is // commit it was built from, and goreleaser's snapshot template matches
// deliberately not a number. // that shape. It is deliberately not a number: a build that is not a
// 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,12 +60,9 @@ 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. "dev" and goreleaser's snapshot "dev-<sha>" count, and so // a release. Both "dev" and "dev-<sha>" (and its "-dirty" variant)
// does what `git describe --tags --always --dirty` gives a make or // count: a caller that compares against "dev" exactly would treat every
// docker build of an untagged commit: the bare short commit, or // commit-stamped development build as a release.
// 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
@@ -74,12 +71,7 @@ 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 {
if v == "" || v == DevVersion || strings.HasPrefix(v, DevVersion+"-") || return 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.
+6 -16
View File
@@ -34,11 +34,10 @@ func TestGlobalsNew(t *testing.T) {
} }
// TestIsDevVersion covers the boundary that matters: everything // TestIsDevVersion covers the boundary that matters: everything
// script/version, a plain docker build and goreleaser's snapshot // script/version and goreleaser's snapshot template can emit for an
// template can emit for an untagged build must be recognised as a // untagged build must be recognised as a development build, and a real
// development build, and a real tag must not be. A plain equality check // tag must not be. A plain equality check against "dev" used to decide
// against "dev" used to decide this, which classified every // this, which classified every commit-stamped dev build as a release.
// commit-stamped dev build as a release.
func TestIsDevVersion(t *testing.T) { func TestIsDevVersion(t *testing.T) {
t.Parallel() t.Parallel()
@@ -50,17 +49,8 @@ func TestIsDevVersion(t *testing.T) {
{"dev", true}, {"dev", true},
{"dev-b6e4a218a39e", true}, {"dev-b6e4a218a39e", true},
{"dev-b6e4a218a39e-dirty", true}, {"dev-b6e4a218a39e-dirty", true},
// What `git describe --tags --always --dirty` produces with no // What a tagged build produces (script/version strips the
// tag reachable, and on a commit after a tag. // leading "v", matching goreleaser's .Version).
{"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,11 +24,9 @@ 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. # check RUNs, so dependency and module layers still cache. Both
# Dockerfile.lint also refuses to build at all when CHECK_EPOCH is # Dockerfiles also refuse to build at all when CHECK_EPOCH is empty,
# empty, so a missing value fails its build loudly rather than passing # so a missing value fails loudly here rather than passing quietly.
# 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
@@ -59,10 +57,12 @@ 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, # same way script/docker does, and passed into the product build so
# where they take precedence over what the build would derive from # the CI-built image reports its real source. The build context
# the .git in its context. VERSION comes from script/version, as in # excludes .git (see .dockerignore), so the build cannot derive them
# the Makefile. # 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.
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)"
+13 -10
View File
@@ -1,8 +1,7 @@
#!/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.
# The tag comes from script/projectname. Unlike the canonical copy in # Identical in all repos; the tag comes from script/projectname.
# sneak/prompts, it passes a fresh CHECK_EPOCH instead of --no-cache, and # Generic: needs no adaptation.
# 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
@@ -22,15 +21,19 @@ 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. # whether the tree is clean. The Dockerfile now refuses to build
# 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 and # Version, commit and build date are computed here on the host,
# passed into the build, where they take precedence over what the # where .git exists, and passed into the build. The build context
# build would derive from the .git in its context. VERSION comes # excludes .git (see .dockerignore), so the container cannot derive
# from script/version, as in the Makefile, so the image reports the # them itself -- it used to try and always got "unknown", giving
# same string, -dirty included, that a local build of the same tree # every image a "commit: unknown" it could not be traced from.
# would. # VERSION comes from script/version, the source of truth shared with
# 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)"
+60 -16
View File
@@ -1,29 +1,73 @@
#!/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. The Makefile's LDFLAGS # Our own extension to scripts-to-rule-them-all, and the single source
# call this rather than carrying a hardcoded constant, which is what used # of truth for the version: the Makefile's LDFLAGS call this rather
# to make every local build claim to be 1.0.0-rc.1 regardless of git # than carrying a hardcoded constant, which is what used to make every
# state. script/docker and script/cibuild pass its output to the image # local build claim to be 1.0.0-rc.1 regardless of git state.
# build; given no version, the image build runs `git describe --tags
# --always` itself, without `--dirty`.
# #
# The version is `git describe --tags --always --dirty`: the tag on a # The rules, in order:
# 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".
# #
# Nothing here ever invents a version number. # 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.
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)"
echo "${version:-dev}" 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}"
} }
main "$@" main "$@"