Add runtime version reporting
This commit is contained in:
17
README.md
17
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.
|
||||
|
||||
12
buildinfo/version.go
Normal file
12
buildinfo/version.go
Normal 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()
|
||||
}
|
||||
81
buildinfo/version_integration_test.go
Normal file
81
buildinfo/version_integration_test.go
Normal 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
23
cmd/releasekit/main.go
Normal 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
|
||||
}
|
||||
35
cmd/releasekit/main_test.go
Normal file
35
cmd/releasekit/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
24
docs/runtime.md
Normal file
24
docs/runtime.md
Normal 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>`.
|
||||
26
internal/runtimeinfo/version.go
Normal file
26
internal/runtimeinfo/version.go
Normal 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"
|
||||
}
|
||||
57
internal/runtimeinfo/version_test.go
Normal file
57
internal/runtimeinfo/version_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
7
internal/versioncontract/injection.go
Normal file
7
internal/versioncontract/injection.go
Normal 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"
|
||||
Reference in New Issue
Block a user