All checks were successful
check / check (push) Successful in 3m20s
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.
290 lines
7.4 KiB
Go
290 lines
7.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|