From 56145c3b7e7845623cd35fe5ad36b8df25532db9 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 25 Aug 2026 01:55:09 +0000 Subject: [PATCH] Add build version reporting --- docs/cli.md | 12 +++++++ docs/internal/cli.md | 12 ++++--- docs/roadmap/implementation.md | 2 +- internal/buildinfo/buildinfo.go | 36 +++++++++++++++++++++ internal/buildinfo/buildinfo_test.go | 45 +++++++++++++++++++++++++++ internal/cli/command_contract_test.go | 30 ++++++++++++++++++ internal/cli/run.go | 16 ++++++++++ 7 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 internal/buildinfo/buildinfo.go create mode 100644 internal/buildinfo/buildinfo_test.go diff --git a/docs/cli.md b/docs/cli.md index 21d7fc72..e0f0903a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -10,6 +10,7 @@ defined in [Operations](operations.md). ~~~ notarius help +notarius --version notarius run --input path/to/source.json [--json] [flags] notarius config validate [--config path/to/config.yml] [--pipeline pipeline-id] [--only lane-a,lane-b] notarius pipelines list [--config path/to/config.yml] [--json] @@ -18,6 +19,17 @@ notarius pipelines list [--config path/to/config.yml] [--json] Running Notarius without arguments, or with **help**, **--help**, or **-h**, writes the command summary to standard output and exits with status 0. +`notarius --version` is valid only as the sole root argument. It writes exactly +`notarius ` followed by a newline to standard output and exits with +status 0. A tagged `go install` build can report its main-module stable tag, +and controlled builds can inject a stable tag at link time through +`gitea.maximumdirect.net/eric/notarius/internal/buildinfo.Override`; an ordinary +unversioned checkout reports `development`. Invalid injected version content is +a runtime error with exit status 1, while extra `--version` arguments are a +syntax error with exit status 2. This diagnostic does not replace the +[run-result](integrations/run-result.md) or artifact contracts for downstream +compatibility decisions. + ## run ~~~ diff --git a/docs/internal/cli.md b/docs/internal/cli.md index b470e154..fc79b65b 100644 --- a/docs/internal/cli.md +++ b/docs/internal/cli.md @@ -24,10 +24,14 @@ preparation, and runner mechanics after their inputs are supplied. ## Dispatch And Configuration Handoff -The root dispatcher handles help, configuration validation, pipeline listing, -and a pipeline run. It normalizes injectable options before dispatch so that a -missing production dependency fails as a command error rather than reaching -execution. +The root dispatcher handles help, version reporting, configuration validation, +pipeline listing, and a pipeline run. Version reporting resolves build +information through `internal/buildinfo` before production composition, so the +diagnostic remains available without configuration or runtime collaborators. +The public syntax, streams, exit classes, and version semantics are defined by +the [CLI reference](../cli.md). Other root commands normalize injectable +options before dispatch so that a missing production dependency fails as a +command error rather than reaching execution. Commands that need configuration use one shared loader. The CLI discovers the file, parses it through **internal/core/config**, starts from defaults, applies diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 354ef58c..e635e7cb 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -41,7 +41,7 @@ operation. - Tag CI validates only. Pre-publication local guards remain mandatory because tag CI cannot prevent an already-pushed tag. -## Stage 1: Add Build Version Resolution And `--version` +## Stage 1: Add Build Version Resolution And `--version` ✅ ### Goal diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 00000000..78d8c689 --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,36 @@ +// Package buildinfo resolves the product version embedded in a Notarius build. +package buildinfo + +import ( + "fmt" + "regexp" + "runtime/debug" +) + +var stableVersion = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`) + +// Override is set at link time for controlled builds. +var Override string + +// Version returns the release version embedded in the build, or development +// when the build does not carry a stable release tag. +func Version() (string, error) { + buildVersion := "" + if info, ok := debug.ReadBuildInfo(); ok { + buildVersion = info.Main.Version + } + return resolve(Override, buildVersion) +} + +func resolve(override, buildVersion string) (string, error) { + if override != "" { + if !stableVersion.MatchString(override) { + return "", fmt.Errorf("build version override is not a stable release tag") + } + return override, nil + } + if stableVersion.MatchString(buildVersion) { + return buildVersion, nil + } + return "development", nil +} diff --git a/internal/buildinfo/buildinfo_test.go b/internal/buildinfo/buildinfo_test.go new file mode 100644 index 00000000..1a591044 --- /dev/null +++ b/internal/buildinfo/buildinfo_test.go @@ -0,0 +1,45 @@ +package buildinfo + +import "testing" + +func TestResolve(t *testing.T) { + tests := []struct { + name string + override string + buildVersion string + want string + wantErr bool + }{ + {name: "stable main module version", buildVersion: "v1.2.3", want: "v1.2.3"}, + {name: "zero version", buildVersion: "v0.0.0", want: "v0.0.0"}, + {name: "override takes precedence", override: "v2.3.4", buildVersion: "v1.2.3", want: "v2.3.4"}, + {name: "invalid override", override: "version", buildVersion: "v1.2.3", wantErr: true}, + {name: "override with whitespace", override: " v1.2.3", wantErr: true}, + {name: "leading zero major", buildVersion: "v01.2.3", want: "development"}, + {name: "leading zero minor", buildVersion: "v1.02.3", want: "development"}, + {name: "leading zero patch", buildVersion: "v1.2.03", want: "development"}, + {name: "build version with whitespace", buildVersion: "v1.2.3 ", want: "development"}, + {name: "prerelease", buildVersion: "v1.2.3-rc.1", want: "development"}, + {name: "build suffix", buildVersion: "v1.2.3+build.1", want: "development"}, + {name: "pseudo version", buildVersion: "v0.0.0-20260102030405-abcdef123456", want: "development"}, + {name: "development build", buildVersion: "(devel)", want: "development"}, + {name: "missing build information", want: "development"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolve(tt.override, tt.buildVersion) + if tt.wantErr { + if err == nil { + t.Fatal("resolve() error = nil, want error") + } + return + } + if err != nil { + t.Fatalf("resolve() error = %v", err) + } + if got != tt.want { + t.Fatalf("resolve() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/cli/command_contract_test.go b/internal/cli/command_contract_test.go index 70c5c355..981bb5f7 100644 --- a/internal/cli/command_contract_test.go +++ b/internal/cli/command_contract_test.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strings" "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/buildinfo" ) func TestCommandHelpSpellingsWriteUsageToStdout(t *testing.T) { @@ -38,6 +40,7 @@ func TestCommandSyntaxErrorsUseStderrAndExitTwo(t *testing.T) { {name: "unknown pipelines subcommand", args: []string{"pipelines", "unknown"}, want: "unknown pipelines subcommand"}, {name: "malformed run flag", args: []string{"run", "demo", "--chunk_cache", "invalid"}, want: "not supported"}, {name: "unknown flag", args: []string{"config", "validate", "--unknown"}, want: "flag provided but not defined"}, + {name: "version arguments", args: []string{"--version", "extra"}, want: "--version does not accept arguments"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -50,6 +53,33 @@ func TestCommandSyntaxErrorsUseStderrAndExitTwo(t *testing.T) { } } +func TestCommandVersionOutput(t *testing.T) { + previous := buildinfo.Override + t.Cleanup(func() { buildinfo.Override = previous }) + + tests := []struct { + name string + override string + wantCode int + wantStdout string + wantStderr string + }{ + {name: "development", wantStdout: "notarius development\n"}, + {name: "release override", override: "v1.2.3", wantStdout: "notarius v1.2.3\n"}, + {name: "invalid override", override: "release", wantCode: 1, wantStderr: "notarius: build version override is not a stable release tag\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buildinfo.Override = tt.override + var stdout, stderr bytes.Buffer + code := RunWithOptions([]string{"--version"}, &stdout, &stderr, Options{}) + if code != tt.wantCode || stdout.String() != tt.wantStdout || stderr.String() != tt.wantStderr { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + }) + } +} + func TestConfigDiscoveryPrefersExplicitPathThenEnvironment(t *testing.T) { explicit := writeCommandConfig(t, "explicit", "alpha") environment := writeCommandConfig(t, "environment", "beta") diff --git a/internal/cli/run.go b/internal/cli/run.go index 609b302d..156200eb 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -16,6 +16,7 @@ import ( "strings" "time" + "gitea.maximumdirect.net/eric/notarius/internal/buildinfo" "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "gitea.maximumdirect.net/eric/notarius/internal/core/config" "gitea.maximumdirect.net/eric/notarius/internal/core/debugbundle" @@ -31,6 +32,7 @@ import ( const defaultConfigPath = "/usr/local/etc/notarius/config.yml" const usage = `Usage: notarius help + notarius --version notarius run --input path/to/source.json [--json] [flags] notarius config validate --config path/to/config.yml [--pipeline pipeline-id] [--only lane-a,lane-b] notarius pipelines list --config path/to/config.yml [--json] @@ -62,6 +64,20 @@ func Run(args []string, stdout, stderr io.Writer) int { } func RunWithOptions(args []string, stdout, stderr io.Writer, opts Options) int { + if len(args) > 0 && args[0] == "--version" { + if len(args) != 1 { + fmt.Fprintln(stderr, "notarius: --version does not accept arguments") + return 2 + } + version, err := buildinfo.Version() + if err != nil { + fmt.Fprintf(stderr, "notarius: %v\n", err) + return 1 + } + fmt.Fprintf(stdout, "notarius %s\n", version) + return 0 + } + var err error opts, err = normalizeOptions(opts) if err != nil {