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