Add runtime version reporting

This commit is contained in:
2026-10-05 16:49:54 +00:00
parent 9e855c81a6
commit d692607e2b
11 changed files with 292 additions and 9 deletions

View File

@@ -1,10 +1,11 @@
# ReleaseKit # ReleaseKit
ReleaseKit is an opinionated Go library and executable for a shared release ReleaseKit is an opinionated Go library and executable for a shared release
process. The Go module and documented root `releasekit` facade are established; process. The Go module, root `releasekit` facade, `buildinfo` runtime package,
the library operations, runtime version package, executable, tests, and release and `releasekit version` command are established; the library operations beyond
automation remain to be implemented. Their selected behavior and sequence are runtime version access, remaining CLI commands, and release automation remain
described in the [product roadmap](docs/roadmap/releasekit.md) and 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). [implementation plan](docs/roadmap/implementation.md).
Run the offline Go test suite from the repository root with: 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 ./... go test ./...
``` ```
The current command prints ReleaseKit's version:
```sh
go run ./cmd/releasekit version
```
## Documentation ## Documentation
- [ReleaseKit proposal](docs/roadmap/releasekit.md): proposed publication, builds, - [ReleaseKit proposal](docs/roadmap/releasekit.md): proposed publication, builds,
@@ -21,6 +28,8 @@ go test ./...
policy reading. policy reading.
- [Architecture policy](docs/policy/architecture.md): responsibility and dependency - [Architecture policy](docs/policy/architecture.md): responsibility and dependency
boundaries for development. 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. - [ADR 0001](docs/adr/0001-public-facade.md): the accepted public facade decision.
- [Policy adoption roadmap](docs/roadmap/policy.md): completed adoption and pending - [Policy adoption roadmap](docs/roadmap/policy.md): completed adoption and pending
release bootstrap work. release bootstrap work.

12
buildinfo/version.go Normal file
View File

@@ -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()
}

View File

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

23
cmd/releasekit/main.go Normal file
View File

@@ -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
}

View File

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

View File

@@ -42,10 +42,10 @@ Run the offline Go test suite from the repository root with:
go test ./... go test ./...
``` ```
The implemented package inventory currently contains only the root The implemented package inventory currently contains the root `releasekit`
`releasekit` facade package. Its public operations are added as their behavior is facade and `buildinfo` runtime facade, plus the `cmd/releasekit` executable.
implemented; see the [implementation plan](roadmap/implementation.md) for Orchestration operations are added as their behavior is implemented; see the
current progress. [implementation plan](roadmap/implementation.md) for current progress.
For prose changes, review the diff and check local links, fragment destinations, For prose changes, review the diff and check local links, fragment destinations,
provenance, template residue, and consistency with current repository contents. provenance, template residue, and consistency with current repository contents.

View File

@@ -456,7 +456,7 @@ LGPL licensing and attribution notices remain consistent.
## Stage 2: Implement the runtime version facade ## 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 1. Implement `buildinfo.Version` and its internal injected-string and main-module
fallback logic. Keep runtime state private and the public accessor small. 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. version reports that version. Fallback metadata uses the executable main module.
CLI version output is correct and no runtime orchestration initialization occurs. 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 ## Stage 3: Protect public contracts and dependency direction
Status: Pending. Depends on Stage 2. Status: Pending. Depends on Stage 2.

24
docs/runtime.md Normal file
View File

@@ -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 <version>`.

View File

@@ -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"
}

View File

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

View File

@@ -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"