Compare commits
1 Commits
fec6876c42
...
ec92992450
| Author | SHA1 | Date | |
|---|---|---|---|
| ec92992450 |
18
Dockerfile
18
Dockerfile
@@ -61,14 +61,28 @@ RUN script/fetch-assets
|
|||||||
|
|
||||||
# Run tests and build
|
# Run tests and build
|
||||||
RUN make test
|
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.
|
# Rebuild with static linking for Alpine runtime.
|
||||||
# make build already verified compilation.
|
# make build already verified compilation.
|
||||||
# The CGO binary from `make build` is dynamically linked against glibc,
|
# The CGO binary from `make build` is dynamically linked against glibc,
|
||||||
# which doesn't exist on Alpine (musl). Rebuild with static linking so
|
# which doesn't exist on Alpine (musl). Rebuild with static linking so
|
||||||
# the binary runs on Alpine without glibc.
|
# 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
|
# Runtime stage
|
||||||
# alpine:3.21, 2026-03-17
|
# alpine:3.21, 2026-03-17
|
||||||
|
|||||||
17
Makefile
17
Makefile
@@ -1,8 +1,18 @@
|
|||||||
.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 target
|
||||||
.DEFAULT_GOAL := check
|
.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)
|
||||||
|
|
||||||
|
# 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:
|
bootstrap:
|
||||||
@script/bootstrap
|
@script/bootstrap
|
||||||
|
|
||||||
@@ -28,7 +38,7 @@ check:
|
|||||||
@script/check
|
@script/check
|
||||||
|
|
||||||
build:
|
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
|
run: build
|
||||||
./bin/webhooker
|
./bin/webhooker
|
||||||
@@ -40,6 +50,9 @@ deps:
|
|||||||
go mod download
|
go mod download
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|
||||||
|
version:
|
||||||
|
@echo $(VERSION)
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
@script/docker
|
@script/docker
|
||||||
|
|
||||||
|
|||||||
62
README.md
62
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 lint # Run golangci-lint in Docker (Dockerfile.lint)
|
||||||
make test # Run tests with race detection
|
make test # Run tests with race detection
|
||||||
make check # test + lint + fmt-check (CI gate)
|
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 run # build, then run ./bin/webhooker
|
||||||
make dev # go run ./cmd/webhooker
|
make dev # go run ./cmd/webhooker
|
||||||
make deps # go mod download + go mod tidy
|
make deps # go mod download + go mod tidy
|
||||||
@@ -677,6 +678,9 @@ Upgrade procedure:
|
|||||||
3. Pull the new image and start it.
|
3. Pull the new image and start it.
|
||||||
4. Confirm `database migrations completed` in the logs before putting
|
4. Confirm `database migrations completed` in the logs before putting
|
||||||
traffic back on it.
|
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
|
**Downgrade is unsupported.** Once a newer binary has migrated the files
|
||||||
there is no way to move them back. `AutoMigrate` is additive — it adds
|
there is no way to move them back. `AutoMigrate` is additive — it adds
|
||||||
@@ -687,6 +691,40 @@ silent divergence, not a startup error. The only supported way back to
|
|||||||
an older version is restoring the pre-upgrade backup, which discards
|
an older version is restoring the pre-upgrade backup, which discards
|
||||||
everything received since that backup was taken.
|
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.
|
||||||
|
|
||||||
|
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
|
### Backups contain secrets
|
||||||
|
|
||||||
Treat a backup with the same care as the credentials inside it. Encrypt
|
Treat a backup with the same care as the credentials inside it. Encrypt
|
||||||
@@ -829,9 +867,11 @@ anything.
|
|||||||
This repository adheres to the
|
This repository adheres to the
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
standard: normalized scripts in `script/` are the entrypoints for the
|
standard: normalized scripts in `script/` are the entrypoints for the
|
||||||
development workflow. Ten of the Makefile's sixteen targets are thin
|
development workflow. Ten of the Makefile's seventeen targets are thin
|
||||||
shims that call them; `build`, `run`, `dev`, `deps`, `clean` and `css`
|
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
|
||||||
are inline commands with no script behind them. We provide:
|
`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/bootstrap` — install all dependencies (idempotent)
|
||||||
- `script/setup` — make a fresh clone ready for development
|
- `script/setup` — make a fresh clone ready for development
|
||||||
@@ -844,7 +884,11 @@ are inline commands with no script behind them. We provide:
|
|||||||
- `script/fmt` — format all code (writes)
|
- `script/fmt` — format all code (writes)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
- `script/check` — run test, lint, and fmt-check
|
- `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
|
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
|
||||||
runs the checks, so a green build implies a green repo)
|
runs the checks, so a green build implies a green repo)
|
||||||
- `script/ci-mark-superseded` — CI helper: mark the commits whose run a
|
- `script/ci-mark-superseded` — CI helper: mark the commits whose run a
|
||||||
@@ -2379,7 +2423,7 @@ webhooker/
|
|||||||
├── script/ # Scripts to Rule Them All entrypoints
|
├── script/ # Scripts to Rule Them All entrypoints
|
||||||
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
||||||
├── Dockerfile.lint # Lint-only image built by script/lint
|
├── 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
|
├── go.mod / go.sum
|
||||||
└── .golangci.yml # Linter configuration
|
└── .golangci.yml # Linter configuration
|
||||||
```
|
```
|
||||||
@@ -2679,7 +2723,11 @@ version is fixed independently of the compiler's:
|
|||||||
stage passing (it copies a file from it), runs `script/fetch-assets`
|
stage passing (it copies a file from it), runs `script/fetch-assets`
|
||||||
to download and verify the third-party browser assets, then runs
|
to download and verify the third-party browser assets, then runs
|
||||||
`make test` and `make build`, and finally rebuilds the binary with
|
`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,
|
3. **Runtime stage** (`alpine:3.21`) — copies the static binary,
|
||||||
creates the `/var/lib/webhooker` directory for all SQLite databases,
|
creates the `/var/lib/webhooker` directory for all SQLite databases,
|
||||||
runs as the non-root `webhooker` user (UID 1000), exposes port 8080,
|
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 {
|
type templateDataWrapper struct {
|
||||||
User *UserInfo
|
User *UserInfo
|
||||||
CSRFToken string
|
CSRFToken string
|
||||||
|
Version string
|
||||||
Data any
|
Data any
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,9 +235,16 @@ func (s *Handlers) renderTemplate(
|
|||||||
userInfo := s.getUserInfo(r)
|
userInfo := s.getUserInfo(r)
|
||||||
csrfToken := middleware.CSRFToken(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 {
|
if m, ok := data.(map[string]any); ok {
|
||||||
m["User"] = userInfo
|
m["User"] = userInfo
|
||||||
m["CSRFToken"] = csrfToken
|
m["CSRFToken"] = csrfToken
|
||||||
|
m["Version"] = version
|
||||||
s.executeTemplate(w, tmpl, m)
|
s.executeTemplate(w, tmpl, m)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -245,6 +253,7 @@ func (s *Handlers) renderTemplate(
|
|||||||
wrapper := templateDataWrapper{
|
wrapper := templateDataWrapper{
|
||||||
User: userInfo,
|
User: userInfo,
|
||||||
CSRFToken: csrfToken,
|
CSRFToken: csrfToken,
|
||||||
|
Version: version,
|
||||||
Data: data,
|
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
|
||||||
289
internal/versionscript/version_script_test.go
Normal file
289
internal/versionscript/version_script_test.go
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
package versionscript_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
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)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
#!/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.
|
||||||
# Generic: needs no adaptation.
|
#
|
||||||
|
# .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
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
@@ -9,7 +12,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
docker build -t "$("$SCRIPT_DIR/projectname")" .
|
docker build \
|
||||||
|
--build-arg VERSION="$("$SCRIPT_DIR/version")" \
|
||||||
|
-t "$("$SCRIPT_DIR/projectname")" .
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
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