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:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user