Files
webhooker/internal/versionscript/version_script_test.go
sneak fec6876c42
All checks were successful
check / check (push) Successful in 3m6s
Stamp the build version into the binary (closes #253)
The binary reported "dev" in every deployment: main.version carried a
placeholder and nothing ever set it. Neither `make build` nor the
Dockerfile passed -X, so a tagged release produced an artifact that
could not say which commit it was, and the upgrade procedure's "confirm
the new build is live" step had nothing to confirm against.

script/version is now the single source of the value: $VERSION when
set, else `git describe --tags --always --dirty`, else "unknown". A
clean checkout at a tag reports exactly that tag; a tree with no git
metadata reports "unknown" rather than failing or naming a tag it may
not be at. Nothing time- or host-dependent is stamped, so two builds of
one commit stay byte-identical.

The Makefile's build target composes the flags -- `-X main.version` plus
whatever GO_LDFLAGS adds -- and every compile goes through it, including
the Dockerfile's static relink, which now contributes its -extldflags
through GO_LDFLAGS instead of replacing -ldflags wholesale. Since
.dockerignore excludes .git/, the image cannot derive the version: it
takes a VERSION build arg, defaulted to "unknown", that script/docker
fills in from the host checkout.

The UI footer needed the other half of the fix. It renders .Version,
which nothing ever put in the template data, so it printed its literal
"dev" fallback no matter what the binary was built as; renderTemplate
now supplies the value on both the map and the wrapper path.

An empty VERSION means unset in the Makefile too, not only in
script/version: `make build VERSION=` and a `--build-arg VERSION=`
reaching the Dockerfile's `make build VERSION="$VERSION"` both define
the variable as the empty string, which the script's guard never sees
and which would stamp no version at all -- putting the footer back on
its "dev" fallback, the defect this change exists to remove. The guard
needs `override`: a plain assignment loses to the command-line
definition it exists to correct.
2026-08-24 00:37:14 +00:00

346 lines
9.4 KiB
Go

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)
}
}
}