Take the version from the git tag, not a constant (closes #80)
check / check (push) Waiting to run

`mfer version`, `mfer --version` and the banner print the revision the
build stamps into main.Gitrev or, when it stamped none (as after
`go install` of a tag), the main module's version from
runtime/debug.ReadBuildInfo. The banner no longer shows a release date.

mfer.Version and mfer.ReleaseDate go, with main.Version and the version
field and parameter in internal/cli that nothing read. A program using
the library reads the module version from its own build information.
The README says where the version comes from.

Judgement call: a binary with no build information, or none holding a
module version, prints "unknown", the word script/build already uses
when git describe gives nothing.

Model: opus-5-5
This commit is contained in:
2026-10-13 19:24:17 +00:00
parent 24496880c5
commit c0c6496f8e
7 changed files with 73 additions and 36 deletions
+9
View File
@@ -17,6 +17,15 @@ The current version is pre-1.0 and while the repo was published in 2022, there
has not yet been any versioned release. [SemVer](https://semver.org) will be has not yet been any versioned release. [SemVer](https://semver.org) will be
used for releases. used for releases.
A version is a git tag, and no file in the tree records a version number.
`mfer version` prints the revision the build stamped into the binary, which for
`make build` and the Docker image is the output of `git describe` on the
checkout they build. A binary built without that stamp, as
`go install sneak.berlin/go/mfer/cmd/mfer@<tag>` builds it, prints the module
version Go records in it instead. A program that imports the `mfer` package
finds the version it was built against in its own build information
(`runtime/debug.ReadBuildInfo`).
This project was started by [@sneak](https://sneak.berlin) to scratch an itch in This project was started by [@sneak](https://sneak.berlin) to scratch an itch in
2022 and is currently a one-person effort, though the goal is for this to emerge 2022 and is currently a one-person effort, though the goal is for this to emerge
as a de-facto standard and be incorporated into other software. A compatible as a de-facto standard and be incorporated into other software. A compatible
+4 -6
View File
@@ -10,14 +10,12 @@ import (
// Appname is the name of this program. // Appname is the name of this program.
const Appname = "mfer" const Appname = "mfer"
// Version and Gitrev are injected at build time via -ldflags. // Gitrev is the revision `mfer version` prints, injected at build time via
// -ldflags.
// //
//nolint:gochecknoglobals // set via ldflags at build time //nolint:gochecknoglobals // set via ldflags at build time
var ( var Gitrev string
Version string
Gitrev string
)
func main() { func main() {
os.Exit(cli.Run(Appname, Version, Gitrev)) os.Exit(cli.Run(Appname, Gitrev))
} }
+3 -4
View File
@@ -15,11 +15,10 @@ func TestAppname(t *testing.T) {
assert.Equal(t, "mfer", Appname) assert.Equal(t, "mfer", Appname)
} }
// TestVersionDefaults documents that Version and Gitrev are empty unless // TestGitrevDefault documents that Gitrev is empty unless injected at build
// injected at build time via -ldflags. // time via -ldflags.
func TestVersionDefaults(t *testing.T) { func TestGitrevDefault(t *testing.T) {
t.Parallel() t.Parallel()
assert.Empty(t, Version)
assert.Empty(t, Gitrev) assert.Empty(t, Gitrev)
} }
+4 -7
View File
@@ -26,8 +26,7 @@ func noColorEnvSet() bool {
// Use DefaultRunOptions for standard CLI execution, or construct manually for testing. // Use DefaultRunOptions for standard CLI execution, or construct manually for testing.
type RunOptions struct { type RunOptions struct {
Appname string // Application name displayed in help and version output Appname string // Application name displayed in help and version output
Version string // Version string (typically set at build time) Gitrev string // Revision stamped in at build time, or empty
Gitrev string // Git revision hash (typically set at build time)
Args []string // Command-line arguments (typically os.Args) Args []string // Command-line arguments (typically os.Args)
Stdin io.Reader // Standard input stream Stdin io.Reader // Standard input stream
Stdout io.Writer // Standard output stream Stdout io.Writer // Standard output stream
@@ -36,10 +35,9 @@ type RunOptions struct {
} }
// DefaultRunOptions returns RunOptions configured for normal CLI execution. // DefaultRunOptions returns RunOptions configured for normal CLI execution.
func DefaultRunOptions(appname, version, gitrev string) *RunOptions { func DefaultRunOptions(appname, gitrev string) *RunOptions {
return &RunOptions{ return &RunOptions{
Appname: appname, Appname: appname,
Version: version,
Gitrev: gitrev, Gitrev: gitrev,
Args: os.Args, Args: os.Args,
Stdin: os.Stdin, Stdin: os.Stdin,
@@ -50,15 +48,14 @@ func DefaultRunOptions(appname, version, gitrev string) *RunOptions {
} }
// Run creates and runs the CLI application with default options. // Run creates and runs the CLI application with default options.
func Run(appname, version, gitrev string) int { func Run(appname, gitrev string) int {
return RunWithOptions(DefaultRunOptions(appname, version, gitrev)) return RunWithOptions(DefaultRunOptions(appname, gitrev))
} }
// RunWithOptions creates and runs the CLI application with the given options. // RunWithOptions creates and runs the CLI application with the given options.
func RunWithOptions(opts *RunOptions) int { func RunWithOptions(opts *RunOptions) int {
m := &CLIApp{ m := &CLIApp{
appname: opts.Appname, appname: opts.Appname,
version: opts.Version,
gitrev: opts.Gitrev, gitrev: opts.Gitrev,
exitCode: 0, exitCode: 0,
maxManifestSize: mfer.MaxManifestSize, maxManifestSize: mfer.MaxManifestSize,
+35 -6
View File
@@ -11,6 +11,7 @@ import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"runtime/debug"
"slices" "slices"
"strings" "strings"
"sync" "sync"
@@ -27,6 +28,7 @@ import (
const ( const (
testApp = "mfer" testApp = "mfer"
testGitrev = "abc123"
testDir = "/testdir" testDir = "/testdir"
testFile1 = "/testdir/file1.txt" testFile1 = "/testdir/file1.txt"
testMF = "/testdir/test.mf" testMF = "/testdir/test.mf"
@@ -93,8 +95,7 @@ func TestBuild(t *testing.T) {
func testOpts(args []string, fs afero.Fs) *RunOptions { func testOpts(args []string, fs afero.Fs) *RunOptions {
return &RunOptions{ return &RunOptions{
Appname: testApp, Appname: testApp,
Version: "1.0.0", Gitrev: testGitrev,
Gitrev: "abc123",
Args: args, Args: args,
Stdin: &bytes.Buffer{}, Stdin: &bytes.Buffer{},
Stdout: &bytes.Buffer{}, Stdout: &bytes.Buffer{},
@@ -136,10 +137,35 @@ func TestVersionCommand(t *testing.T) {
exitCode := runCLI(opts) exitCode := runCLI(opts)
assert.Equal(t, 0, exitCode) assert.Equal(t, 0, exitCode)
assert.Equal(t, "mfer version "+testGitrev+"\n", testStdout(t, opts))
}
stdout := testStdout(t, opts) // TestVersionFromBuildInfo covers a binary whose build stamped no Gitrev,
assert.Contains(t, stdout, mfer.Version) // as `go install` builds it: mfer version prints the main module's version
assert.Contains(t, stdout, "abc123") // from the build information instead.
func TestVersionFromBuildInfo(t *testing.T) {
t.Parallel()
info, ok := debug.ReadBuildInfo()
require.True(t, ok)
require.NotEmpty(t, info.Main.Version)
opts := testOpts([]string{testApp, cmdVersion}, afero.NewMemMapFs())
opts.Gitrev = ""
require.Equal(t, 0, runCLI(opts))
assert.Equal(t, "mfer version "+info.Main.Version+"\n", testStdout(t, opts))
}
// TestModuleVersionEmpty covers build information that holds no module
// version, as in a build with GO111MODULE=off, and a binary with no build
// information at all. Both report "unknown": an empty version would print
// nothing after "mfer version" and drop the --version flag.
func TestModuleVersionEmpty(t *testing.T) {
t.Parallel()
assert.Equal(t, "unknown", moduleVersion(&debug.BuildInfo{}, true))
assert.Equal(t, "unknown", moduleVersion(nil, false))
} }
// TestVFlagCollision covers the -v/--verbose vs --version flag interaction // TestVFlagCollision covers the -v/--verbose vs --version flag interaction
@@ -165,7 +191,7 @@ func TestVFlagCollision(t *testing.T) {
exitCode := runCLI(opts) exitCode := runCLI(opts)
assert.Equal(t, 0, exitCode, "stderr: %s", testStderr(t, opts)) assert.Equal(t, 0, exitCode, "stderr: %s", testStderr(t, opts))
assert.Contains(t, testStdout(t, opts), mfer.Version) assert.Contains(t, testStdout(t, opts), testGitrev)
assert.NotContains(t, testStderr(t, opts), "two forms of the same flag") assert.NotContains(t, testStderr(t, opts), "two forms of the same flag")
}) })
} }
@@ -739,6 +765,9 @@ func TestBannerOutput(t *testing.T) {
stdout := testStdout(t, opts) stdout := testStdout(t, opts)
assert.Contains(t, stdout, "___") assert.Contains(t, stdout, "___")
assert.Contains(t, stdout, "\\") assert.Contains(t, stdout, "\\")
// The banner shows the version mfer version prints, and nothing after it.
assert.Contains(t, stdout, "mfer by @sneak: "+testGitrev+"\n")
} }
func TestUnknownCommand(t *testing.T) { func TestUnknownCommand(t *testing.T) {
+18 -7
View File
@@ -6,12 +6,12 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"runtime/debug"
"time" "time"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
"sneak.berlin/go/mfer/internal/log" "sneak.berlin/go/mfer/internal/log"
"sneak.berlin/go/mfer/mfer"
) )
// Command and flag names shared across command definitions and tests. // Command and flag names shared across command definitions and tests.
@@ -58,7 +58,6 @@ var errUnexpectedArgument = errors.New("unexpected argument")
//nolint:revive // established name used throughout the codebase and tests //nolint:revive // established name used throughout the codebase and tests
type CLIApp struct { type CLIApp struct {
appname string appname string
version string
gitrev string gitrev string
startupTime time.Time startupTime time.Time
exitCode int exitCode int
@@ -87,13 +86,26 @@ const banner = `
\ \:\ \ \:\ \ \::/ \ \:\ \ \:\ \ \:\ \ \::/ \ \:\
\__\/ \__\/ \__\/ \__\/` \__\/ \__\/ \__\/ \__\/`
// VersionString returns the version and git revision formatted for display. // VersionString returns the version mfer reports: the revision the build
// stamped into main.Gitrev or, when it stamped none, as with `go install`,
// the main module's version from the binary's build information.
func (mfa *CLIApp) VersionString() string { func (mfa *CLIApp) VersionString() string {
if mfa.gitrev != "" { if mfa.gitrev != "" {
return fmt.Sprintf("%s (%s)", mfer.Version, mfa.gitrev) return mfa.gitrev
} }
return mfer.Version return moduleVersion(debug.ReadBuildInfo())
}
// moduleVersion returns the main module's version from the binary's build
// information, or "unknown" when the binary has none or it holds no version,
// as in a build with GO111MODULE=off.
func moduleVersion(info *debug.BuildInfo, ok bool) string {
if !ok || info.Main.Version == "" {
return "unknown"
}
return info.Main.Version
} }
// printVersion writes the version line shared by the --version flag and the // printVersion writes the version line shared by the --version flag and the
@@ -106,8 +118,7 @@ func (mfa *CLIApp) printBanner() {
if log.GetLevel() <= log.InfoLevel { if log.GetLevel() <= log.InfoLevel {
_, _ = fmt.Fprintln(mfa.Stdout, banner) _, _ = fmt.Fprintln(mfa.Stdout, banner)
_, _ = fmt.Fprintf(mfa.Stdout, _, _ = fmt.Fprintf(mfa.Stdout,
" mfer by @sneak: v%s released %s\n", " mfer by @sneak: %s\n", mfa.VersionString())
mfer.Version, mfer.ReleaseDate)
_, _ = fmt.Fprintln(mfa.Stdout, " https://sneak.berlin/go/mfer") _, _ = fmt.Fprintln(mfa.Stdout, " https://sneak.berlin/go/mfer")
} }
} }
-6
View File
@@ -1,12 +1,6 @@
package mfer package mfer
const ( const (
// Version is the current mfer release version.
Version = "0.1.0"
// ReleaseDate is the date on which Version was released.
ReleaseDate = "2025-12-17"
// MaxDecompressedSize is the maximum allowed size of decompressed manifest // MaxDecompressedSize is the maximum allowed size of decompressed manifest
// data (256 MiB). This prevents decompression bombs from consuming excessive // data (256 MiB). This prevents decompression bombs from consuming excessive
// memory. // memory.