Add runtime version reporting
This commit is contained in:
17
README.md
17
README.md
@@ -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
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 ./...
|
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.
|
||||||
|
|||||||
@@ -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
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