diff --git a/README.md b/README.md index f02aed6..7a206a1 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # ReleaseKit ReleaseKit is an opinionated Go library and executable for a shared release -process. The Go module and documented root `releasekit` facade are established; -the library operations, runtime version package, executable, tests, and release -automation remain to be implemented. Their selected behavior and sequence are -described in the [product roadmap](docs/roadmap/releasekit.md) and +process. The Go module, root `releasekit` facade, `buildinfo` runtime package, +and `releasekit version` command are established; the library operations beyond +runtime version access, remaining CLI commands, and release automation remain +to be implemented. Their selected behavior and sequence are described in the +[product roadmap](docs/roadmap/releasekit.md) and [implementation plan](docs/roadmap/implementation.md). Run the offline Go test suite from the repository root with: @@ -13,6 +14,12 @@ Run the offline Go test suite from the repository root with: go test ./... ``` +The current command prints ReleaseKit's version: + +```sh +go run ./cmd/releasekit version +``` + ## Documentation - [ReleaseKit proposal](docs/roadmap/releasekit.md): proposed publication, builds, @@ -21,6 +28,8 @@ go test ./... policy reading. - [Architecture policy](docs/policy/architecture.md): responsibility and dependency boundaries for development. +- [Runtime version guidance](docs/runtime.md): how applications read their + embedded version through `buildinfo`. - [ADR 0001](docs/adr/0001-public-facade.md): the accepted public facade decision. - [Policy adoption roadmap](docs/roadmap/policy.md): completed adoption and pending release bootstrap work. diff --git a/buildinfo/version.go b/buildinfo/version.go new file mode 100644 index 0000000..8da33c3 --- /dev/null +++ b/buildinfo/version.go @@ -0,0 +1,12 @@ +// Package buildinfo provides runtime version information for applications +// built with ReleaseKit. +package buildinfo + +import "gitea.maximumdirect.net/eric/ReleaseKit/internal/runtimeinfo" + +// Version returns the injected release identifier when available. Otherwise, +// it returns the executable's main module version, or "dev" when that metadata +// is unavailable or identifies a development build. +func Version() string { + return runtimeinfo.Version() +} diff --git a/buildinfo/version_integration_test.go b/buildinfo/version_integration_test.go new file mode 100644 index 0000000..ff8f1af --- /dev/null +++ b/buildinfo/version_integration_test.go @@ -0,0 +1,81 @@ +package buildinfo_test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" +) + +func TestVersionInConsumerExecutable(t *testing.T) { + repo, err := filepath.Abs("..") + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + module := fmt.Sprintf(`module example.com/versionfixture + +go 1.25.0 + +require gitea.maximumdirect.net/eric/ReleaseKit v0.0.0 + +replace gitea.maximumdirect.net/eric/ReleaseKit => %s +`, filepath.ToSlash(repo)) + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(module), 0o600); err != nil { + t.Fatal(err) + } + program := `package main + +import ( + "fmt" + "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" +) + +func main() { fmt.Print(buildinfo.Version()) } +` + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(program), 0o600); err != nil { + t.Fatal(err) + } + + for _, test := range []struct { + name string + linkerArgs []string + want string + }{ + {name: "development build", want: "dev"}, + { + name: "injected release version", + linkerArgs: []string{"-X", versioncontract.InjectedVersionLinkerSymbol + "=v1.2.3"}, + want: "v1.2.3", + }, + } { + t.Run(test.name, func(t *testing.T) { + binary := filepath.Join(t.TempDir(), "versionfixture") + args := []string{"build", "-o", binary} + if len(test.linkerArgs) > 0 { + args = append(args, "-ldflags", fmt.Sprint(test.linkerArgs[0], " ", test.linkerArgs[1])) + } + args = append(args, ".") + + build := exec.Command("go", args...) + build.Dir = dir + build.Env = append(os.Environ(), "GOTOOLCHAIN=local", "GOPROXY=off", "GOSUMDB=off") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build consumer fixture: %v\n%s", err, output) + } + + command := exec.Command(binary) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("run consumer fixture: %v\n%s", err, output) + } + if string(output) != test.want { + t.Fatalf("version output = %q, want %q", output, test.want) + } + }) + } +} diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go new file mode 100644 index 0000000..361bb1a --- /dev/null +++ b/cmd/releasekit/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "fmt" + "io" + "os" + + "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout, stderr io.Writer) int { + if len(args) == 1 && args[0] == "version" { + fmt.Fprintf(stdout, "releasekit %s\n", buildinfo.Version()) + return 0 + } + + fmt.Fprintln(stderr, "usage: releasekit version") + return 2 +} diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go new file mode 100644 index 0000000..82f4abb --- /dev/null +++ b/cmd/releasekit/main_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "bytes" + "fmt" + "testing" + + "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" +) + +func TestVersionCommand(t *testing.T) { + var stdout, stderr bytes.Buffer + if status := run([]string{"version"}, &stdout, &stderr); status != 0 { + t.Fatalf("run() status = %d, want 0; stderr = %q", status, stderr.String()) + } + if want := fmt.Sprintf("releasekit %s\n", buildinfo.Version()); stdout.String() != want { + t.Fatalf("version output = %q, want %q", stdout.String(), want) + } + if stderr.Len() != 0 { + t.Fatalf("version command wrote to stderr: %q", stderr.String()) + } +} + +func TestUnknownCommand(t *testing.T) { + var stdout, stderr bytes.Buffer + if status := run([]string{"publish"}, &stdout, &stderr); status != 2 { + t.Fatalf("run() status = %d, want 2", status) + } + if stdout.Len() != 0 { + t.Fatalf("unknown command wrote to stdout: %q", stdout.String()) + } + if want := "usage: releasekit version\n"; stderr.String() != want { + t.Fatalf("usage output = %q, want %q", stderr.String(), want) + } +} diff --git a/docs/development.md b/docs/development.md index 8e88403..5879b69 100644 --- a/docs/development.md +++ b/docs/development.md @@ -42,10 +42,10 @@ Run the offline Go test suite from the repository root with: go test ./... ``` -The implemented package inventory currently contains only the root -`releasekit` facade package. Its public operations are added as their behavior is -implemented; see the [implementation plan](roadmap/implementation.md) for -current progress. +The implemented package inventory currently contains the root `releasekit` +facade and `buildinfo` runtime facade, plus the `cmd/releasekit` executable. +Orchestration operations are added as their behavior is implemented; see the +[implementation plan](roadmap/implementation.md) for current progress. For prose changes, review the diff and check local links, fragment destinations, provenance, template residue, and consistency with current repository contents. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9fd1560..e56596a 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -456,7 +456,7 @@ LGPL licensing and attribution notices remain consistent. ## Stage 2: Implement the runtime version facade -Status: Pending. Depends on Stage 1. +Status: Complete. Depends on Stage 1. 1. Implement `buildinfo.Version` and its internal injected-string and main-module fallback logic. Keep runtime state private and the public accessor small. @@ -470,6 +470,15 @@ Acceptance: a source-built fixture reports `dev`; a fixture linked with a stable version reports that version. Fallback metadata uses the executable main module. CLI version output is correct and no runtime orchestration initialization occurs. +Completion evidence: implemented `buildinfo.Version`, its private injected +string and main-module fallback, and the internal linker-symbol contract. Unit +tests cover injection precedence and missing, development, and stable main +module metadata. Built and ran a separate consumer executable from source and +with an injected stable version using network-disabled Go commands; both +reported the selected value. CLI tests cover its version line, and the CLI's +dependency closure contains no orchestration package. Added runtime consumer +guidance and documented the command contract. + ## Stage 3: Protect public contracts and dependency direction Status: Pending. Depends on Stage 2. diff --git a/docs/runtime.md b/docs/runtime.md new file mode 100644 index 0000000..f608fc8 --- /dev/null +++ b/docs/runtime.md @@ -0,0 +1,24 @@ +# Runtime version + +Applications can read their embedded version through the public `buildinfo` +package: + +```go +import "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" + +version := buildinfo.Version() +``` + +`Version` returns the identifier injected into the executable when present. +Otherwise it returns the executable's main module version when that metadata is +available and is not `(devel)`. It returns `dev` when neither source provides a +release identifier. Applications should use this accessor for version output; +the runtime implementation and ReleaseKit's builder own the injection detail. + +ReleaseKit's current command prints its own runtime version: + +```sh +go run ./cmd/releasekit version +``` + +The output has the form `releasekit `. diff --git a/internal/runtimeinfo/version.go b/internal/runtimeinfo/version.go new file mode 100644 index 0000000..a4e4702 --- /dev/null +++ b/internal/runtimeinfo/version.go @@ -0,0 +1,26 @@ +// Package runtimeinfo implements the private runtime version state used by the +// public buildinfo facade. +package runtimeinfo + +import "runtime/debug" + +var injectedVersion string + +// Version returns the injected version or the running executable's main module +// version, falling back to "dev" when neither provides a release identifier. +func Version() string { + return version(injectedVersion, debug.ReadBuildInfo) +} + +func version(injected string, readBuildInfo func() (*debug.BuildInfo, bool)) string { + if injected != "" { + return injected + } + + info, ok := readBuildInfo() + if ok && info != nil && info.Main.Version != "" && info.Main.Version != "(devel)" { + return info.Main.Version + } + + return "dev" +} diff --git a/internal/runtimeinfo/version_test.go b/internal/runtimeinfo/version_test.go new file mode 100644 index 0000000..9d53891 --- /dev/null +++ b/internal/runtimeinfo/version_test.go @@ -0,0 +1,57 @@ +package runtimeinfo + +import ( + "runtime/debug" + "testing" +) + +func TestVersion(t *testing.T) { + tests := []struct { + name string + injected string + info *debug.BuildInfo + ok bool + want string + }{ + { + name: "injected version takes precedence", + injected: "v1.2.3", + info: &debug.BuildInfo{Main: debug.Module{Version: "v4.5.6"}}, + ok: true, + want: "v1.2.3", + }, + { + name: "main module version", + info: &debug.BuildInfo{Main: debug.Module{Version: "v4.5.6"}}, + ok: true, + want: "v4.5.6", + }, + { + name: "missing build information", + want: "dev", + }, + { + name: "development main module", + info: &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}, + ok: true, + want: "dev", + }, + { + name: "empty main module version", + info: &debug.BuildInfo{Main: debug.Module{}}, + ok: true, + want: "dev", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := version(test.injected, func() (*debug.BuildInfo, bool) { + return test.info, test.ok + }) + if got != test.want { + t.Fatalf("version() = %q, want %q", got, test.want) + } + }) + } +} diff --git a/internal/versioncontract/injection.go b/internal/versioncontract/injection.go new file mode 100644 index 0000000..9a52e6b --- /dev/null +++ b/internal/versioncontract/injection.go @@ -0,0 +1,7 @@ +// Package versioncontract holds the internal contract shared by runtime +// version reporting and the release builder. +package versioncontract + +// InjectedVersionLinkerSymbol is the -X linker target used to set the runtime +// version. Keep it in sync with runtimeinfo.injectedVersion. +const InjectedVersionLinkerSymbol = "gitea.maximumdirect.net/eric/ReleaseKit/internal/runtimeinfo.injectedVersion"