diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ea42132 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Include version information on `git archive' +/version/version.go export-subst \ No newline at end of file diff --git a/internal/specialcmd/specialcmd.go b/internal/specialcmd/specialcmd.go index 6f46fe6..a27f08b 100644 --- a/internal/specialcmd/specialcmd.go +++ b/internal/specialcmd/specialcmd.go @@ -10,12 +10,14 @@ package specialcmd import ( _ "embed" "fmt" - "github.com/janpfeifer/gonb/internal/jpyexec" - "golang.org/x/exp/slices" "os" "strings" "time" + "github.com/janpfeifer/gonb/internal/jpyexec" + "github.com/janpfeifer/gonb/version" + "golang.org/x/exp/slices" + . "github.com/janpfeifer/gonb/common" "github.com/janpfeifer/gonb/gonbui/protocol" "github.com/janpfeifer/gonb/internal/goexec" @@ -239,13 +241,7 @@ func execSpecialConfig(msg kernel.Message, goExec *goexec.State, cmdStr string, klog.Errorf("Failed publishing help contents: %+v", err) } case "version": - gitHash := os.Getenv(protocol.GONB_GIT_COMMIT) - gitVersion := os.Getenv(protocol.GONB_VERSION) - gitCommitURL := fmt.Sprintf("https://github.com/janpfeifer/gonb/tree/%s", gitHash) - gitTagURL := fmt.Sprintf("https://github.com/janpfeifer/gonb/releases/tag/%s", gitVersion) - - err := kernel.PublishMarkdown(msg, fmt.Sprintf( - "**GoNB** version [%s](%s) / Commit: [%s](%s)\n", gitVersion, gitTagURL, gitHash, gitCommitURL)) + err := kernel.PublishMarkdown(msg, version.AppVersion.Markdown()) if err != nil { klog.Errorf("Failed publishing version contents: %+v", err) } diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..37c27f3 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,136 @@ +package version + +import ( + "fmt" + "runtime" + "runtime/debug" + "strconv" + "strings" +) + +type VersionInfo struct { + Version string + Commit string + CommitLink string + ReleaseLink string +} + +const ( + BaseVersionControlURL string = "https://github.com/janpfeifer/gonb" +) + +// AppVersion determines version and commit information based on multiple data sources: +// - AppVersion information dynamically added by `git archive` in the remaining to parameters. +// - A hardcoded version number passed as first parameter. +// - Commit information added to the binary by `go build`. +// +// It's supposed to be called like this in combination with setting the `export-subst` attribute for the corresponding +// file in .gitattributes: +// +// var AppVersion = version.AppVersion("1.0.0-rc1", "$Format:%(describe)$", "$Format:%H$") +// +// When exported using `git archive`, the placeholders are replaced in the file and this version information is +// preferred. Otherwise the hardcoded version is used and augmented with commit information from the build metadata. +// +// Source: https://github.com/Icinga/icingadb/blob/51068fff46364385f3c0165aab7b7393fa6a303b/pkg/version/version.go +func AppVersion(version, gitVersion, gitHash string) *VersionInfo { + if !strings.HasPrefix(gitVersion, "$") && !strings.HasPrefix(gitHash, "$") { + versionInfo := &VersionInfo{ + Version: gitVersion, + Commit: gitHash, + ReleaseLink: fmt.Sprintf("%s/release/%s", BaseVersionControlURL, gitVersion), + } + if len(gitHash) > 0 { + versionInfo.CommitLink = fmt.Sprintf("%s/tree/%s", BaseVersionControlURL, gitHash) + } + + return versionInfo + } else { + var commit string + var releaseVersion string + + if info, ok := debug.ReadBuildInfo(); ok { + modified := false + + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + commit = setting.Value + case "vcs.modified": + modified, _ = strconv.ParseBool(setting.Value) + } + if strings.Contains(setting.Key, "ldflags") && + strings.Contains(setting.Value, "git.tag") { + + start := strings.Index(setting.Value, "git.tag=") + 8 + end := strings.Index(setting.Value[start:], "'") + start + version = setting.Value[start:end] + } + } + + // Same truncation length for the commit hash + const hashLen = 7 + releaseVersion = version + + if len(commit) >= hashLen { + if modified { + version += "-dirty" + commit += " (modified)" + } + } + } + + versionInfo := &VersionInfo{ + Version: version, + Commit: commit, + ReleaseLink: fmt.Sprintf("%s/release/%s", BaseVersionControlURL, releaseVersion), + } + if len(commit) > 0 { + versionInfo.CommitLink = fmt.Sprintf("%s/tree/%s", BaseVersionControlURL, commit) + } + + return versionInfo + } +} + +// GetInfo Get version info +func (v *VersionInfo) GetInfo() VersionInfo { + return *v +} + +// String Get version as a string +func (v *VersionInfo) String() string { + return v.Version +} + +// Print writes verbose version output to stdout. +func (v *VersionInfo) Print() { + fmt.Println("GoNB version:", v.Version) + fmt.Println() + + if len(v.CommitLink) > 0 { + fmt.Println("Version control info:") + fmt.Printf(" Commit: %s \n", v.CommitLink) + fmt.Printf(" Release: %s \n", v.ReleaseLink) + fmt.Println() + } + + fmt.Println("Build info:") + fmt.Printf(" Go version: %s (OS: %s, arch: %s)\n", runtime.Version(), runtime.GOOS, runtime.GOARCH) +} + +func (v *VersionInfo) Markdown() string { + var markdown string + markdown += fmt.Sprintf("## GoNB version: `%s`\n\n", v.Version) + + if len(v.CommitLink) > 0 { + markdown += "### Version Control Info\n" + markdown += fmt.Sprintf("- Commit: [%s](%s)\n", v.Commit, v.CommitLink) + markdown += fmt.Sprintf("- Release: [%s](%s)\n\n", v.Version, v.ReleaseLink) + } + + markdown += "### Build Info\n" + markdown += fmt.Sprintf("- Go version: %s (OS: %s, Arch: %s)\n", runtime.Version(), runtime.GOOS, runtime.GOARCH) + + return markdown +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..0385260 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,133 @@ +package version + +import ( + "bytes" + "os" + "runtime" + "strings" + "testing" +) + +func TestAppVersion(t *testing.T) { + tests := []struct { + name string + version string + gitDescribe string + gitHash string + want *VersionInfo + }{ + { + name: "With git information", + version: "1.0.0", + gitDescribe: "v1.0.0", + gitHash: "abc1234", + want: &VersionInfo{ + Version: "v1.0.0", + Commit: "abc1234", + CommitLink: "https://github.com/janpfeifer/gonb/tree/abc1234", + }, + }, + { + name: "Without git information", + version: "1.0.0", + gitDescribe: "$Format:%(describe)$", + gitHash: "$Format:%H$", + want: &VersionInfo{ + Version: "1.0.0", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := AppVersion(tt.version, tt.gitDescribe, tt.gitHash) + + if got.Version != tt.want.Version { + t.Errorf("AppVersion().Version = %v, want %v", got.Version, tt.want.Version) + } + + if got.Commit != tt.want.Commit { + t.Errorf("AppVersion().Commit = %v, want %v", got.Commit, tt.want.Commit) + } + + if got.CommitLink != tt.want.CommitLink { + t.Errorf("AppVersion().VersionControlLink = %v, want %v", got.CommitLink, tt.want.CommitLink) + } + }) + } +} + +func TestVersionInfo_GetInfo(t *testing.T) { + v := &VersionInfo{ + Version: "1.0.0", + Commit: "abc123", + CommitLink: "https://github.com/janpfeifer/gonb/tree/abc123", + } + + got := v.GetInfo() + if got != *v { + t.Errorf("VersionInfo.GetInfo() = %v, want %v", got, *v) + } +} + +func TestVersionInfo_String(t *testing.T) { + v := &VersionInfo{ + Version: "1.0.0", + Commit: "abc123", + CommitLink: "https://github.com/janpfeifer/gonb/tree/abc123", + } + + got := v.String() + if got != v.Version { + t.Errorf("VersionInfo.String() = %v, want %v", got, v.Version) + } +} + +func TestVersionInfo_Print(t *testing.T) { + v := &VersionInfo{ + Version: "1.0.0", + Commit: "abc123", + CommitLink: "https://github.com/janpfeifer/gonb/tree/abc123", + } + + // Capture output to verify it contains expected information + output := captureOutput(func() { + v.Print() + }) + + // Check if output contains expected information + expectedStrings := []string{ + "GoNB version: 1.0.0", + "Version control info:", + v.CommitLink, + "Build info:", + runtime.Version(), + runtime.GOOS, + runtime.GOARCH, + } + + for _, expected := range expectedStrings { + if !strings.Contains(output, expected) { + t.Errorf("Print() output missing expected string: %s", expected) + } + } +} + +// Helper function to capture stdout dynamically +func captureOutput(f func()) string { + // Redirect stdout to a buffer + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Call the function + f() + + // Restore stdout and read buffer + w.Close() + os.Stdout = old + + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + return buf.String() +} diff --git a/main.go b/main.go index 5503d52..b0ddf8d 100644 --- a/main.go +++ b/main.go @@ -3,27 +3,31 @@ package main import ( "flag" "fmt" - "github.com/gofrs/uuid" - "github.com/janpfeifer/gonb/internal/dispatcher" - "github.com/janpfeifer/gonb/internal/goexec" - "github.com/janpfeifer/gonb/internal/kernel" "io" - klog "k8s.io/klog/v2" "log" "os" "os/exec" "time" + + "github.com/gofrs/uuid" + "github.com/janpfeifer/gonb/internal/dispatcher" + "github.com/janpfeifer/gonb/internal/goexec" + "github.com/janpfeifer/gonb/internal/kernel" + "github.com/janpfeifer/gonb/version" + klog "k8s.io/klog/v2" ) var ( - flagInstall = flag.Bool("install", false, "Install kernel in local config, and make it available in Jupyter") - flagKernel = flag.String("kernel", "", "ProgramExecutor kernel using given path for the `connection_file` provided by Jupyter client") - flagExtraLog = flag.String("extra_log", "", "Extra file to include in the log.") - flagForceDeps = flag.Bool("force_deps", false, "Force install even if goimports and/or gopls are missing.") - flagForceCopy = flag.Bool("force_copy", false, "Copy binary to the Jupyter kernel configuration location. This already happens by default is the binary is under `/tmp`.") - flagRawError = flag.Bool("raw_error", false, "When GoNB executes cells, force raw text errors instead of HTML errors, which facilitates command line testing of notebooks.") - flagWork = flag.Bool("work", false, "Print name of temporary work directory and preserve it at exit. ") - flagCommsLog = flag.Bool("comms_log", false, "Enable verbose logging from communication library in Javascript console.") + flagInstall = flag.Bool("install", false, "Install kernel in local config, and make it available in Jupyter") + flagKernel = flag.String("kernel", "", "ProgramExecutor kernel using given path for the `connection_file` provided by Jupyter client") + flagExtraLog = flag.String("extra_log", "", "Extra file to include in the log.") + flagForceDeps = flag.Bool("force_deps", false, "Force install even if goimports and/or gopls are missing.") + flagForceCopy = flag.Bool("force_copy", false, "Copy binary to the Jupyter kernel configuration location. This already happens by default is the binary is under `/tmp`.") + flagRawError = flag.Bool("raw_error", false, "When GoNB executes cells, force raw text errors instead of HTML errors, which facilitates command line testing of notebooks.") + flagWork = flag.Bool("work", false, "Print name of temporary work directory and preserve it at exit. ") + flagCommsLog = flag.Bool("comms_log", false, "Enable verbose logging from communication library in Javascript console.") + flagShortVersion = flag.Bool("V", false, "Print version information") + flagLongVersion = flag.Bool("version", false, "Print detailed version information") ) var ( @@ -51,6 +55,10 @@ func main() { flag.Parse() + if printVersion() { + return + } + // Setup logging. if *flagExtraLog != "" { logFile, err := os.OpenFile(*flagExtraLog, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) @@ -150,6 +158,17 @@ func main() { klog.Infof("Exiting...") } +func printVersion() bool { + if *flagShortVersion { + fmt.Println(version.AppVersion.String()) + return true + } else if *flagLongVersion { + version.AppVersion.Print() + return true + } + return false +} + var ( ColorReset = "\033[0m" ColorYellow = "\033[33m" @@ -179,17 +198,17 @@ func prepend[T any](slice []T, value T) []T { } // Filter implements klog.LogFilter interface. -func (_ UniqueIDFilter) Filter(args []interface{}) []interface{} { +func (UniqueIDFilter) Filter(args []interface{}) []interface{} { return prepend(args, any(coloredUniqueID)) } // FilterF implements klog.LogFilter interface. -func (_ UniqueIDFilter) FilterF(format string, args []interface{}) (string, []interface{}) { +func (UniqueIDFilter) FilterF(format string, args []interface{}) (string, []interface{}) { return "%s" + format, prepend(args, any(coloredUniqueID)) } // FilterS implements klog.LogFilter interface. -func (_ UniqueIDFilter) FilterS(msg string, keysAndValues []interface{}) (string, []interface{}) { +func (UniqueIDFilter) FilterS(msg string, keysAndValues []interface{}) (string, []interface{}) { return coloredUniqueID + msg, keysAndValues } diff --git a/version.go b/version.go index 7717520..d624a42 100644 --- a/version.go +++ b/version.go @@ -1,12 +1,14 @@ package main import ( - "github.com/janpfeifer/gonb/gonbui/protocol" "os" + + "github.com/janpfeifer/gonb/gonbui/protocol" + "github.com/janpfeifer/gonb/version" ) -//go:generate bash -c "printf 'package main\nvar GitTag = \"%s\"\n' \"$(git describe --tags --abbrev=0)\" > versiontag.go" -//go:generate bash -c "printf 'package main\nvar GitCommitHash = \"%s\"\n' \"$(git rev-parse HEAD)\" > versionhash.go" +//go:generate bash -c "printf 'package main\nvar GitTag = \"%s\"\n' \"$(git describe --tags --abbrev=0)\" > version/versiontag.go" +//go:generate bash -c "printf 'package main\nvar GitCommitHash = \"%s\"\n' \"$(git rev-parse HEAD)\" > version/versionhash.go" func must(err error) { if err != nil { @@ -15,6 +17,10 @@ func must(err error) { } func init() { - must(os.Setenv(protocol.GONB_GIT_COMMIT, GitCommitHash)) - must(os.Setenv(protocol.GONB_VERSION, GitTag)) + gitCommit := version.AppVersion.Commit + if gitCommit == "" { + gitCommit = version.GitCommitHash + } + must(os.Setenv(protocol.GONB_GIT_COMMIT, gitCommit)) + must(os.Setenv(protocol.GONB_VERSION, version.AppVersion.Version)) } diff --git a/version/version.go b/version/version.go new file mode 100644 index 0000000..dd5f6ef --- /dev/null +++ b/version/version.go @@ -0,0 +1,8 @@ +package version + +import "github.com/janpfeifer/gonb/internal/version" + +// AppVersion contains version and Git commit information. +// +// The placeholders are replaced on `git archive` using the `export-subst` attribute. +var AppVersion = version.AppVersion(GitTag, "$Format:%(describe)$", "$Format:%H$") diff --git a/version/versionhash.go b/version/versionhash.go new file mode 100644 index 0000000..9e2fccb --- /dev/null +++ b/version/versionhash.go @@ -0,0 +1,3 @@ +package version + +var GitCommitHash = "05c5124f6ed019954e5ed195f7e2674a8713beca" diff --git a/version/versiontag.go b/version/versiontag.go new file mode 100644 index 0000000..5a8f382 --- /dev/null +++ b/version/versiontag.go @@ -0,0 +1,3 @@ +package version + +var GitTag = "v0.10.10" diff --git a/versionhash.go b/versionhash.go deleted file mode 100644 index a49e284..0000000 --- a/versionhash.go +++ /dev/null @@ -1,2 +0,0 @@ -package main -var GitCommitHash = "0e5f587a077810d058202b76a127651a02bd4382" diff --git a/versiontag.go b/versiontag.go deleted file mode 100644 index 8692445..0000000 --- a/versiontag.go +++ /dev/null @@ -1,2 +0,0 @@ -package main -var GitTag = "v0.10.6"