Stamp the build version into the binary (closes #253)
All checks were successful
check / check (push) Successful in 3m12s
All checks were successful
check / check (push) Successful in 3m12s
This commit was merged in pull request #260.
This commit is contained in:
18
Dockerfile
18
Dockerfile
@@ -61,14 +61,28 @@ RUN script/fetch-assets
|
||||
|
||||
# Run tests and build
|
||||
RUN make test
|
||||
RUN make build
|
||||
|
||||
# Version stamped into the binary. .dockerignore excludes .git/, so
|
||||
# nothing in this stage can derive it: script/docker resolves it on the
|
||||
# host and passes it in. The default is what a bare `docker build .`
|
||||
# with no --build-arg gets, and it names no tag the tree may not be at.
|
||||
#
|
||||
# Declared here, below the test and asset steps, so a changed version
|
||||
# does not invalidate their cached layers.
|
||||
ARG VERSION=unknown
|
||||
|
||||
RUN make build VERSION="$VERSION"
|
||||
|
||||
# Rebuild with static linking for Alpine runtime.
|
||||
# make build already verified compilation.
|
||||
# The CGO binary from `make build` is dynamically linked against glibc,
|
||||
# which doesn't exist on Alpine (musl). Rebuild with static linking so
|
||||
# the binary runs on Alpine without glibc.
|
||||
RUN CGO_ENABLED=1 go build -ldflags '-extldflags "-static"' -o bin/webhooker ./cmd/webhooker
|
||||
#
|
||||
# The static flags go in through GO_LDFLAGS rather than a -ldflags of
|
||||
# their own: the build target composes them with the -X that stamps the
|
||||
# version, so this relink cannot silently drop the stamp.
|
||||
RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'
|
||||
|
||||
# Runtime stage
|
||||
# alpine:3.21, 2026-03-17
|
||||
|
||||
25
Makefile
25
Makefile
@@ -1,8 +1,26 @@
|
||||
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css
|
||||
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css version
|
||||
|
||||
# Default target
|
||||
.DEFAULT_GOAL := check
|
||||
|
||||
# Version stamped into the binary. Derived from git by script/version;
|
||||
# override it (`make build VERSION=v1.2.3`) where git metadata is
|
||||
# unavailable, which is how the Dockerfile passes its build arg in.
|
||||
VERSION ?= $(shell script/version)
|
||||
|
||||
# An empty override (`make build VERSION=`, or a `--build-arg VERSION=`
|
||||
# landing on the Dockerfile's `make build VERSION="$VERSION"`) means unset,
|
||||
# exactly as it does in script/version -- stamping "" would leave the binary
|
||||
# reporting no version and the footer back on its "dev" fallback. `override`
|
||||
# is required: a plain assignment loses to the command-line definition it
|
||||
# exists to correct.
|
||||
override VERSION := $(or $(strip $(VERSION)),$(shell script/version))
|
||||
|
||||
# Extra linker flags for the build target. The static relink in the
|
||||
# Dockerfile adds -extldflags here rather than passing its own -ldflags,
|
||||
# so composing flags cannot drop the version stamp.
|
||||
GO_LDFLAGS ?=
|
||||
|
||||
bootstrap:
|
||||
@script/bootstrap
|
||||
|
||||
@@ -28,7 +46,7 @@ check:
|
||||
@script/check
|
||||
|
||||
build:
|
||||
go build -o bin/webhooker ./cmd/webhooker
|
||||
go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
|
||||
|
||||
run: build
|
||||
./bin/webhooker
|
||||
@@ -40,6 +58,9 @@ deps:
|
||||
go mod download
|
||||
go mod tidy
|
||||
|
||||
version:
|
||||
@echo $(VERSION)
|
||||
|
||||
docker:
|
||||
@script/docker
|
||||
|
||||
|
||||
64
README.md
64
README.md
@@ -57,7 +57,8 @@ make fmt-check # Fail if gofmt would change anything (writes nothing)
|
||||
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
||||
make test # Run tests with race detection
|
||||
make check # test + lint + fmt-check (CI gate)
|
||||
make build # Build binary to bin/webhooker
|
||||
make build # Build binary to bin/webhooker (version-stamped)
|
||||
make version # Print the version this checkout would stamp
|
||||
make run # build, then run ./bin/webhooker
|
||||
make dev # go run ./cmd/webhooker
|
||||
make deps # go mod download + go mod tidy
|
||||
@@ -691,6 +692,9 @@ Upgrade procedure:
|
||||
3. Pull the new image and start it.
|
||||
4. Confirm `database migrations completed` in the logs before putting
|
||||
traffic back on it.
|
||||
5. Confirm the new build is the one running:
|
||||
`curl -s http://host:8080/.well-known/healthcheck` reports the
|
||||
version it was stamped with (see [Version stamping](#version-stamping)).
|
||||
|
||||
**Downgrade is unsupported.** Once a newer binary has migrated the files
|
||||
there is no way to move them back. `AutoMigrate` is additive — it adds
|
||||
@@ -701,6 +705,42 @@ silent divergence, not a startup error. The only supported way back to
|
||||
an older version is restoring the pre-upgrade backup, which discards
|
||||
everything received since that backup was taken.
|
||||
|
||||
### Version stamping
|
||||
|
||||
The binary reports its version at `/.well-known/healthcheck` (the
|
||||
`version` field), in the UI footer, and in the startup log line
|
||||
(`msg=starting`, `version=...`). It is also the Sentry release name,
|
||||
as `webhooker-{version}`. The value is stamped in at build time by the
|
||||
linker; it is not read from a file at runtime, so it identifies the
|
||||
build itself.
|
||||
|
||||
`script/version` produces the value and both build paths use it:
|
||||
|
||||
| Build | What it reports |
|
||||
| --- | --- |
|
||||
| Clean checkout at a tag | exactly that tag, e.g. `v1.0.0` |
|
||||
| Commits past a tag | `v1.0.0-3-g1a2b3c4` — tag, commits since, short SHA |
|
||||
| No tag reachable | the short SHA, e.g. `1a2b3c4` |
|
||||
| Uncommitted changes | the above with a `-dirty` suffix |
|
||||
| No git metadata | `unknown` |
|
||||
|
||||
`unknown` is what a source tarball or a `docker build .` with no
|
||||
`--build-arg VERSION=...` reports. `.dockerignore` excludes `.git/`, so
|
||||
the build context carries no git metadata and the image cannot derive
|
||||
the version itself: `script/docker` (and so `make docker`) resolves it
|
||||
on the host and passes it in as the `VERSION` build arg. A build that
|
||||
reports `unknown` is a build nobody told what it was; it is not a
|
||||
failure, but it cannot be traced back to a commit.
|
||||
|
||||
`make version` prints what the current checkout would stamp, and
|
||||
`make build VERSION=v1.2.3` overrides it. An empty override — from
|
||||
`make build VERSION=` or from `--build-arg VERSION=` — means unset
|
||||
rather than `""`, and resolves the way an absent one does.
|
||||
|
||||
Nothing that varies between two builds of the same commit is stamped —
|
||||
no timestamp, no hostname, no builder identity — so two builds of one
|
||||
commit still produce a byte-identical binary.
|
||||
|
||||
### Backups contain secrets
|
||||
|
||||
Treat a backup with the same care as the credentials inside it. Encrypt
|
||||
@@ -843,9 +883,11 @@ anything.
|
||||
This repository adheres to the
|
||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||
standard: normalized scripts in `script/` are the entrypoints for the
|
||||
development workflow. Ten of the Makefile's sixteen targets are thin
|
||||
shims that call them; `build`, `run`, `dev`, `deps`, `clean` and `css`
|
||||
are inline commands with no script behind them. We provide:
|
||||
development workflow. Ten of the Makefile's seventeen targets are thin
|
||||
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
|
||||
`version` are inline commands with no script behind them, though
|
||||
`build` and `version` both take their value from `script/version`. We
|
||||
provide:
|
||||
|
||||
- `script/bootstrap` — install all dependencies (idempotent)
|
||||
- `script/setup` — make a fresh clone ready for development
|
||||
@@ -858,7 +900,11 @@ are inline commands with no script behind them. We provide:
|
||||
- `script/fmt` — format all code (writes)
|
||||
- `script/fmt-check` — check formatting (read-only)
|
||||
- `script/check` — run test, lint, and fmt-check
|
||||
- `script/docker` — build the Docker image tagged via `script/projectname`
|
||||
- `script/version` — output the version to stamp into the binary (see
|
||||
[Version stamping](#version-stamping))
|
||||
- `script/docker` — build the Docker image tagged via
|
||||
`script/projectname`, passing `script/version`'s output in as the
|
||||
`VERSION` build arg
|
||||
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
|
||||
runs the checks, so a green build implies a green repo)
|
||||
- `script/ci-mark-superseded` — CI helper: mark the commits whose run a
|
||||
@@ -2430,7 +2476,7 @@ webhooker/
|
||||
├── script/ # Scripts to Rule Them All entrypoints
|
||||
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
||||
├── Dockerfile.lint # Lint-only image built by script/lint
|
||||
├── Makefile # 10 of 16 targets shim script/; 6 are inline
|
||||
├── Makefile # 10 of 17 targets shim script/; 7 are inline
|
||||
├── go.mod / go.sum
|
||||
└── .golangci.yml # Linter configuration
|
||||
```
|
||||
@@ -2735,7 +2781,11 @@ version is fixed independently of the compiler's:
|
||||
stage passing (it copies a file from it), runs `script/fetch-assets`
|
||||
to download and verify the third-party browser assets, then runs
|
||||
`make test` and `make build`, and finally rebuilds the binary with
|
||||
`CGO_ENABLED=1` and static linking so it runs on musl.
|
||||
`CGO_ENABLED=1` and static linking so it runs on musl. Both builds
|
||||
go through `make build`, the relink adding its `-extldflags` via
|
||||
`GO_LDFLAGS`, so neither can drop the `-X` that stamps the version.
|
||||
The version arrives as the `VERSION` build arg, since the context
|
||||
has no `.git` (see [Version stamping](#version-stamping)).
|
||||
3. **Runtime stage** (`alpine:3.21`) — copies the static binary,
|
||||
creates the `/var/lib/webhooker` directory for all SQLite databases,
|
||||
runs as the non-root `webhooker` user (UID 1000), exposes port 8080,
|
||||
|
||||
38
internal/handlers/footer_version_test.go
Normal file
38
internal/handlers/footer_version_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// The footer in base.html falls back to the literal "dev" when the
|
||||
// template data carries no version, which is what every page rendered
|
||||
// while nothing supplied one. The operator uses the footer to tell
|
||||
// which build is live, so it has to carry the stamped value.
|
||||
func TestFooterReportsStampedVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
g *globals.Globals
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &g)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
g.Version = "v9.9.9-test"
|
||||
|
||||
html := renderPage(t, h, sess, "login.html", map[string]any{
|
||||
dataKeyError: "",
|
||||
})
|
||||
|
||||
assert.Contains(t, html, "<span>v9.9.9-test</span>")
|
||||
assert.NotContains(t, html, "<span>dev</span>")
|
||||
}
|
||||
@@ -184,6 +184,7 @@ type UserInfo struct {
|
||||
type templateDataWrapper struct {
|
||||
User *UserInfo
|
||||
CSRFToken string
|
||||
Version string
|
||||
Data any
|
||||
}
|
||||
|
||||
@@ -234,9 +235,16 @@ func (s *Handlers) renderTemplate(
|
||||
userInfo := s.getUserInfo(r)
|
||||
csrfToken := middleware.CSRFToken(r)
|
||||
|
||||
// The footer in base.html renders .Version. Every page reaches it
|
||||
// through here, so this is the one place that has to supply it;
|
||||
// left unset, the footer falls back to its literal "dev" and the
|
||||
// UI reports a build that is not the one running.
|
||||
version := s.params.Globals.Version
|
||||
|
||||
if m, ok := data.(map[string]any); ok {
|
||||
m["User"] = userInfo
|
||||
m["CSRFToken"] = csrfToken
|
||||
m["Version"] = version
|
||||
s.executeTemplate(w, tmpl, m)
|
||||
|
||||
return
|
||||
@@ -245,6 +253,7 @@ func (s *Handlers) renderTemplate(
|
||||
wrapper := templateDataWrapper{
|
||||
User: userInfo,
|
||||
CSRFToken: csrfToken,
|
||||
Version: version,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
|
||||
12
internal/versionscript/doc.go
Normal file
12
internal/versionscript/doc.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// Package versionscript holds the tests for script/version and for the
|
||||
// build files that consume it. It carries no runtime code: the version
|
||||
// string is produced by a shell script at build time and reaches the
|
||||
// binary through a linker flag, so nothing in the Go build graph can
|
||||
// assert it, but the behaviour still has to be verified by the test
|
||||
// suite.
|
||||
//
|
||||
// The files under test are outside the Go build graph, so `go test`'s
|
||||
// result cache serves a stale PASS when only script/version, the
|
||||
// Makefile or the Dockerfile changed: run the container build, or
|
||||
// GOFLAGS=-count=1, to trust a result here after editing them.
|
||||
package versionscript
|
||||
345
internal/versionscript/version_script_test.go
Normal file
345
internal/versionscript/version_script_test.go
Normal file
@@ -0,0 +1,345 @@
|
||||
package versionscript_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
repoRoot = "../.."
|
||||
scriptPath = "../../script/version"
|
||||
makefilePath = "../../Makefile"
|
||||
dockerfilePath = "../../Dockerfile"
|
||||
|
||||
// unknown is what a tree with no git metadata and no $VERSION must
|
||||
// report: a source tarball has no way to know its version, and the
|
||||
// one thing it must not do is name a tag it may not be at.
|
||||
unknown = "unknown"
|
||||
|
||||
// scriptMode keeps the copied script runnable; dirMode and fileMode
|
||||
// are the ordinary permissions for the throwaway tree around it.
|
||||
scriptMode = 0o755
|
||||
dirMode = 0o750
|
||||
fileMode = 0o600
|
||||
)
|
||||
|
||||
// checkout is a throwaway working tree carrying a copy of the script
|
||||
// under test at the same path it lives at in this repository, since the
|
||||
// script resolves the checkout root from its own location.
|
||||
type checkout struct {
|
||||
dir string
|
||||
head string
|
||||
}
|
||||
|
||||
func TestVersion_CleanTagReportsExactlyTheTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newCheckout(t)
|
||||
c.git(t, "tag", "v1.2.3")
|
||||
|
||||
require.Equal(t, "v1.2.3", c.version(t))
|
||||
}
|
||||
|
||||
func TestVersion_CommitAfterTagCarriesDistanceAndSHA(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newCheckout(t)
|
||||
c.git(t, "tag", "v1.2.3")
|
||||
head := c.commit(t, "after the tag")
|
||||
|
||||
got := c.version(t)
|
||||
|
||||
require.NotEqual(t, "v1.2.3", got,
|
||||
"a commit past the tag must not claim to be the tag")
|
||||
require.True(t, strings.HasPrefix(got, "v1.2.3-1-g"),
|
||||
"want describe form v1.2.3-1-g<sha>, got %q", got)
|
||||
require.True(t, strings.HasPrefix(head, strings.TrimPrefix(
|
||||
got, "v1.2.3-1-g")),
|
||||
"%q must carry the abbreviated head SHA of %q", got, head)
|
||||
}
|
||||
|
||||
func TestVersion_UntaggedHistoryReportsShortSHA(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newCheckout(t)
|
||||
|
||||
got := c.version(t)
|
||||
|
||||
require.NotEmpty(t, got)
|
||||
require.NotEqual(t, unknown, got)
|
||||
require.True(t, strings.HasPrefix(c.head, got),
|
||||
"%q must be an abbreviation of head %q", got, c.head)
|
||||
}
|
||||
|
||||
func TestVersion_UncommittedChangesAreMarkedDirty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newCheckout(t)
|
||||
c.git(t, "tag", "v1.2.3")
|
||||
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(c.dir, "tracked.txt"), []byte("edited\n"), fileMode,
|
||||
))
|
||||
|
||||
require.Equal(t, "v1.2.3-dirty", c.version(t))
|
||||
}
|
||||
|
||||
// A source tarball, or any build context without .git, still has to
|
||||
// build. It reports "unknown" rather than failing or naming a tag.
|
||||
func TestVersion_NoGitMetadataReportsUnknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newCheckout(t)
|
||||
require.NoError(t, os.RemoveAll(filepath.Join(c.dir, ".git")))
|
||||
|
||||
require.Equal(t, unknown, c.version(t))
|
||||
}
|
||||
|
||||
// An unpacked tarball can sit inside an unrelated working copy. The
|
||||
// enclosing repository's version is not this tree's version.
|
||||
func TestVersion_EnclosingRepositoryIsNotUsed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
outer := newCheckout(t)
|
||||
outer.git(t, "tag", "v9.9.9")
|
||||
|
||||
inner := filepath.Join(outer.dir, "unpacked")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(inner, "script"), dirMode))
|
||||
copyScript(t, inner)
|
||||
|
||||
require.Equal(t, unknown, runScript(t, inner, nil))
|
||||
}
|
||||
|
||||
// The Docker build has no git metadata, so the version arrives as an
|
||||
// environment override. It wins over anything derivable.
|
||||
func TestVersion_EnvironmentOverrideWins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newCheckout(t)
|
||||
c.git(t, "tag", "v1.2.3")
|
||||
|
||||
require.Equal(t, "v4.5.6",
|
||||
runScript(t, c.dir, []string{"VERSION=v4.5.6"}))
|
||||
}
|
||||
|
||||
// An empty VERSION is treated as unset rather than stamping an empty
|
||||
// string: the Dockerfile's build arg has a non-empty default, but a
|
||||
// caller exporting VERSION= must not produce a binary reporting "".
|
||||
func TestVersion_EmptyOverrideFallsBackToGit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newCheckout(t)
|
||||
c.git(t, "tag", "v1.2.3")
|
||||
|
||||
require.Equal(t, "v1.2.3", runScript(t, c.dir, []string{"VERSION="}))
|
||||
}
|
||||
|
||||
// Two builds of the same commit must produce a byte-identical binary,
|
||||
// which they cannot if the stamped value moves between invocations.
|
||||
func TestVersion_IsStableAcrossInvocations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newCheckout(t)
|
||||
c.git(t, "tag", "v1.2.3")
|
||||
|
||||
first := c.version(t)
|
||||
second := c.version(t)
|
||||
|
||||
require.Equal(t, first, second)
|
||||
}
|
||||
|
||||
// The build target is the only place composing linker flags. If a
|
||||
// future edit drops either half, the binary silently reports "dev"
|
||||
// again (the defect this package exists for) or fails to link on
|
||||
// Alpine.
|
||||
func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
makefile := read(t, makefilePath)
|
||||
|
||||
require.Contains(t, makefile, "-X main.version=$(VERSION)")
|
||||
require.Contains(t, makefile, "$(GO_LDFLAGS)")
|
||||
require.Contains(t, makefile, "VERSION ?= $(shell script/version)")
|
||||
}
|
||||
|
||||
// A caller can define VERSION as the empty string -- `make build
|
||||
// VERSION=`, or a `--build-arg VERSION=` reaching the Dockerfile's `make
|
||||
// build VERSION="$VERSION"`. script/version's own guard does not cover
|
||||
// that: the value never passes through the script. Stamping "" would
|
||||
// leave the binary reporting no version and the footer on "dev", which
|
||||
// is the defect this package exists for.
|
||||
func TestMakefile_EmptyOverrideResolvesLikeAnUnsetOne(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A plain assignment would be ignored here: a command-line
|
||||
// definition outranks it, and that is the case being corrected.
|
||||
require.Contains(t, read(t, makefilePath), "override VERSION :=")
|
||||
|
||||
requireMake(t)
|
||||
|
||||
derived := makeVersion(t)
|
||||
require.NotEmpty(t, derived)
|
||||
|
||||
require.Equal(t, derived, makeVersion(t, "VERSION="),
|
||||
"an empty VERSION must resolve the way an unset one does")
|
||||
require.Equal(t, "v9.9.9", makeVersion(t, "VERSION=v9.9.9"),
|
||||
"the empty guard must not clobber a real override")
|
||||
}
|
||||
|
||||
// makeVersion runs this repository's `version` target, which prints the
|
||||
// value `make build` would stamp, with the given command-line
|
||||
// definitions.
|
||||
func makeVersion(t *testing.T, defs ...string) string {
|
||||
t.Helper()
|
||||
|
||||
//nolint:gosec // fixed argv, arguments are test constants
|
||||
cmd := exec.CommandContext(t.Context(), "make",
|
||||
append([]string{"--no-print-directory", "version"}, defs...)...)
|
||||
cmd.Dir = repoRoot
|
||||
|
||||
// Only the command-line definitions may decide the outcome: an
|
||||
// inherited VERSION would change what an unset one resolves to, and
|
||||
// an inherited MAKEFLAGS carries the parent's jobserver.
|
||||
cmd.Env = append(os.Environ(), "VERSION=", "MAKEFLAGS=", "MAKELEVEL=")
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func requireMake(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
_, err := exec.LookPath("make")
|
||||
if err != nil {
|
||||
t.Skipf("make is not installed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Every compile in the image goes through the build target, so the
|
||||
// static relink cannot replace the flags that carry the stamp.
|
||||
func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dockerfile := read(t, dockerfilePath)
|
||||
|
||||
require.NotContains(t, dockerfile, "go build",
|
||||
"a raw go build bypasses the Makefile's -X flag")
|
||||
require.Contains(t, dockerfile, "ARG VERSION=")
|
||||
require.Contains(t, dockerfile,
|
||||
`make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'`)
|
||||
}
|
||||
|
||||
func read(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
|
||||
//nolint:gosec // repo-local build file under test, fixed path
|
||||
b, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// version runs the script in this checkout with no overrides.
|
||||
func (c checkout) version(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return runScript(t, c.dir, nil)
|
||||
}
|
||||
|
||||
func runScript(t *testing.T, dir string, env []string) string {
|
||||
t.Helper()
|
||||
|
||||
//nolint:gosec // fixed argv, repo-local script under test
|
||||
cmd := exec.CommandContext(t.Context(), "sh",
|
||||
filepath.Join(dir, "script", "version"))
|
||||
cmd.Dir = dir
|
||||
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func (c checkout) git(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
|
||||
//nolint:gosec // fixed argv, arguments are test constants
|
||||
cmd := exec.CommandContext(t.Context(), "git", args...)
|
||||
cmd.Dir = c.dir
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func (c checkout) commit(t *testing.T, message string) string {
|
||||
t.Helper()
|
||||
|
||||
c.git(t,
|
||||
"-c", "user.email=ci@example.invalid",
|
||||
"-c", "user.name=ci",
|
||||
"-c", "commit.gpgsign=false",
|
||||
"commit", "-q", "--allow-empty", "-m", message,
|
||||
)
|
||||
|
||||
return c.git(t, "rev-parse", "HEAD")
|
||||
}
|
||||
|
||||
// newCheckout builds a one-commit repository with a tracked file, so a
|
||||
// later edit to that file makes the tree dirty, and with a copy of the
|
||||
// script at the path it occupies in this repository.
|
||||
func newCheckout(t *testing.T) checkout {
|
||||
t.Helper()
|
||||
|
||||
requireGit(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
c := checkout{dir: dir}
|
||||
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "script"), dirMode))
|
||||
copyScript(t, dir)
|
||||
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(dir, "tracked.txt"), []byte("original\n"), fileMode,
|
||||
))
|
||||
|
||||
c.git(t, "init", "-q", "-b", "main")
|
||||
c.git(t, "add", "tracked.txt")
|
||||
c.head = c.commit(t, "initial")
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func copyScript(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
|
||||
body, err := os.ReadFile(scriptPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:gosec // the copy has to stay executable to be run
|
||||
err = os.WriteFile(
|
||||
filepath.Join(dir, "script", "version"), body, scriptMode,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func requireGit(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
for _, tool := range []string{"sh", "git"} {
|
||||
_, err := exec.LookPath(tool)
|
||||
if err != nil {
|
||||
t.Skipf("%s is not installed: %v", tool, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
#!/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.
|
||||
#
|
||||
# .dockerignore excludes .git/, so the builder stage cannot derive the
|
||||
# version itself. It is resolved here, where the checkout is, and passed
|
||||
# in as a build arg; without it the image would stamp itself "unknown".
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
@@ -9,7 +12,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build -t "$("$SCRIPT_DIR/projectname")" .
|
||||
docker build \
|
||||
--build-arg VERSION="$("$SCRIPT_DIR/version")" \
|
||||
-t "$("$SCRIPT_DIR/projectname")" .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
65
script/version
Executable file
65
script/version
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/bin/sh
|
||||
# script/version: output the version string the binary is stamped with.
|
||||
# Our own extension to scripts-to-rule-them-all. The Makefile's build
|
||||
# target and script/docker both take the value from here, so a `make
|
||||
# build` binary and a `make docker` image built from the same checkout
|
||||
# report the same thing.
|
||||
#
|
||||
# Order of precedence:
|
||||
#
|
||||
# 1. $VERSION, if set and non-empty. This is how the value reaches a
|
||||
# build that cannot derive it: .dockerignore excludes .git/, so the
|
||||
# builder stage has no git metadata and the Dockerfile takes the
|
||||
# value as a build arg instead.
|
||||
# 2. `git describe --tags --always --dirty` against this checkout. At
|
||||
# a clean tagged commit that is exactly the tag; otherwise it
|
||||
# carries the short SHA, the commit distance when a tag is
|
||||
# reachable, and a -dirty suffix for uncommitted changes.
|
||||
# 3. "unknown", for a tree with no git metadata and no $VERSION -- a
|
||||
# source tarball, or `docker build .` with no --build-arg. That
|
||||
# case must not fail the build and must not name a tag the tree may
|
||||
# not be at, so it names nothing.
|
||||
#
|
||||
# The git step insists the enclosing repository is this checkout, not
|
||||
# merely some repository above it: an unpacked tarball sitting inside an
|
||||
# unrelated working copy would otherwise be stamped with that copy's
|
||||
# version.
|
||||
#
|
||||
# Nothing here may vary between two builds of the same commit: the
|
||||
# release gate asserts the binary is byte-identical across builds. That
|
||||
# rules out a build timestamp, a hostname, and a builder identity.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# in_this_checkout succeeds when git can read metadata for a repository
|
||||
# whose work tree root is $ROOT.
|
||||
in_this_checkout() {
|
||||
command -v git >/dev/null 2>&1 || return 1
|
||||
|
||||
top="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1
|
||||
[ -n "$top" ] || return 1
|
||||
|
||||
top="$(cd "$top" 2>/dev/null && pwd -P)" || return 1
|
||||
[ "$top" = "$ROOT" ]
|
||||
}
|
||||
|
||||
main() {
|
||||
if [ -n "${VERSION:-}" ]; then
|
||||
echo "$VERSION"
|
||||
|
||||
return 0
|
||||
fi
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
if in_this_checkout; then
|
||||
# --always keeps an untagged history from failing the build: it
|
||||
# falls back to the bare short SHA.
|
||||
git describe --tags --always --dirty 2>/dev/null && return 0
|
||||
fi
|
||||
|
||||
echo "unknown"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user