version: implement Go tooling
These two packages implement respectively:
1. A companion Go library to access data from //version/spec
Protobuf data.
2. A Go code generator and related Bazel build infrastructure to convert
Bazel build status data into an embedded //version/spec Protobuf
Version message.
The two allow for stamping Go artifacts with a generated spec.Version
proto, and allows Go code to work with said messages.
The two systems are split to allow decoupling stamping build artifacts
from processing such version messages. This is so that eg. a Metropolis
client tool can receive a server's Version field, and then show that
field to the user.
Change-Id: I82fbfa6bc2418edc979cdc6e1fdee60ee75a88b7
Reviewed-on: https://review.monogon.dev/c/monogon/+/2332
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
Tested-by: Jenkins CI
diff --git a/version/stampgo/BUILD.bazel b/version/stampgo/BUILD.bazel
new file mode 100644
index 0000000..cbcca39
--- /dev/null
+++ b/version/stampgo/BUILD.bazel
@@ -0,0 +1,19 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+ name = "stampgo_lib",
+ srcs = ["main.go"],
+ importpath = "source.monogon.dev/version/stampgo",
+ visibility = ["//visibility:private"],
+ deps = [
+ "//version/spec",
+ "@com_github_coreos_go_semver//semver",
+ "@org_golang_google_protobuf//proto",
+ ],
+)
+
+go_binary(
+ name = "stampgo",
+ embed = [":stampgo_lib"],
+ visibility = ["//visibility:public"],
+)
diff --git a/version/stampgo/main.go b/version/stampgo/main.go
new file mode 100644
index 0000000..0ce658a
--- /dev/null
+++ b/version/stampgo/main.go
@@ -0,0 +1,187 @@
+// Package main implements 'stampgo', a tool to convert build status data into a
+// version.spec.Version proto, which is then embedded into a Go source file.
+package main
+
+import (
+ "bufio"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/coreos/go-semver/semver"
+ "google.golang.org/protobuf/proto"
+
+ "source.monogon.dev/version/spec"
+)
+
+var (
+ flagStatusFile string
+ flagOutFile string
+ flagImportpath string
+ flagProduct string
+)
+
+var (
+ rePrereleaseCommitOffset = regexp.MustCompile(`^dev([0-9]+)$`)
+ rePrereleaseDirty = regexp.MustCompile(`^dirty$`)
+ rePrereleaseGitHash = regexp.MustCompile(`^g[0-9a-f]+$`)
+)
+
+func init() {
+ flag.StringVar(&flagStatusFile, "status_file", "", "path to bazel workspace status file")
+ flag.StringVar(&flagOutFile, "out_file", "version.go", "path to os-release output file")
+ flag.StringVar(&flagImportpath, "importpath", "", "importpath of generated source")
+ flag.StringVar(&flagProduct, "product", "", "product name within the monogon source tree")
+}
+
+// parseStatusFile reads and parses a Bazel build status file and returns
+// a string -> string map based on its contents.
+func parseStatusFile(path string) (map[string]string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("open failed: %w", err)
+ }
+ defer f.Close()
+
+ res := make(map[string]string)
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := scanner.Text()
+ parts := strings.Split(line, " ")
+ if len(parts) != 2 {
+ return nil, fmt.Errorf("invalid line: %q", line)
+ }
+ k, v := parts[0], parts[1]
+ if k == "" {
+ return nil, fmt.Errorf("invalid line: %q", line)
+ }
+ if _, ok := res[k]; ok {
+ return nil, fmt.Errorf("repeated key %q", k)
+ }
+ res[k] = v
+ }
+ return res, nil
+}
+
+func main() {
+ flag.Parse()
+ if flagImportpath == "" {
+ log.Fatalf("Importpath must be set")
+ }
+ importpathParts := strings.Split(flagImportpath, "/")
+ packageName := importpathParts[len(importpathParts)-1]
+
+ values, err := parseStatusFile(flagStatusFile)
+ if err != nil {
+ log.Fatalf("Failed to read workspace status file: %v", err)
+ }
+
+ commitHash := values["STABLE_MONOGON_gitCommit"]
+ if commitHash == "" {
+ log.Fatalf("No git commit in workspace status")
+ }
+ if len(commitHash) < 8 {
+ log.Fatalf("Git commit hash too short")
+ }
+ buildTreeState := spec.Version_GitInformation_BUILD_TREE_STATE_INVALID
+ switch values["STABLE_MONOGON_gitTreeState"] {
+ case "clean":
+ buildTreeState = spec.Version_GitInformation_BUILD_TREE_STATE_CLEAN
+ case "dirty":
+ buildTreeState = spec.Version_GitInformation_BUILD_TREE_STATE_DIRTY
+ default:
+ log.Fatalf("Invalid git tree state %q", values["STABLE_MONOGON_gitTreeState"])
+ }
+
+ version := &spec.Version{
+ GitInformation: &spec.Version_GitInformation{
+ CommitHash: commitHash[:8],
+ BuildTreeState: buildTreeState,
+ },
+ }
+
+ productVersion := values["STABLE_MONOGON_"+flagProduct+"_version"]
+ if productVersion != "" {
+ if productVersion[0] != 'v' {
+ log.Fatalf("Invalid %s version %q: does not start with v", flagProduct, productVersion)
+ }
+ productVersion = productVersion[1:]
+ v, err := semver.NewVersion(productVersion)
+ if err != nil {
+ log.Fatalf("Invalid %s version %q: %v", flagProduct, productVersion, err)
+ }
+ // Parse prerelease strings (v1.2.3-foo-bar -> [foo, bar])
+ for _, el := range v.PreRelease.Slice() {
+ // Skip empty slices which happens when there's a semver string with no
+ // prerelease data.
+ if el == "" {
+ continue
+ }
+ preCommitOffset := rePrereleaseCommitOffset.FindStringSubmatch(el)
+ preDirty := rePrereleaseDirty.FindStringSubmatch(el)
+ preGitHash := rePrereleaseGitHash.FindStringSubmatch(el)
+ switch {
+ case preCommitOffset != nil:
+ offset, err := strconv.ParseUint(preCommitOffset[1], 10, 64)
+ if err != nil {
+ log.Fatalf("Invalid commit offset value: %v", err)
+ }
+ version.GitInformation.CommitsSinceRelease = offset
+ case preDirty != nil:
+ // Ignore field, we have it from the global monorepo state.
+ case preGitHash != nil:
+ // Ignore field, we have it from the global monorepo state.
+ default:
+ log.Fatalf("Invalid prerelease string %q (in %q)", el, productVersion)
+ }
+ }
+ version.Release = &spec.Version_Release{
+ Major: v.Major,
+ Minor: v.Minor,
+ Patch: v.Patch,
+ }
+ }
+
+ versionBytes, err := proto.Marshal(version)
+ literalBytes := make([]string, len(versionBytes))
+ for i, by := range versionBytes {
+ literalBytes[i] = fmt.Sprintf("0x%02x", by)
+ }
+
+ content := []string{
+ `// Generated by //version/stampgo`,
+ `package ` + packageName,
+ ``,
+ `import (`,
+ ` "fmt"`,
+ ` "os"`,
+ ``,
+ ` "google.golang.org/protobuf/proto"`,
+ ``,
+ ` "source.monogon.dev/version/spec"`,
+ `)`,
+ ``,
+ `var raw = []byte{`,
+ ` ` + strings.Join(literalBytes, ", ") + `,`,
+ `}`,
+ ``,
+ `var Version *spec.Version`,
+ ``,
+ `func init() {`,
+ ` var version spec.Version`,
+ ` if err := proto.Unmarshal(raw, &version); err != nil {`,
+ ` fmt.Fprintf(os.Stderr, "Invalid stamped version: %v\n", err)`,
+ ` }`,
+ ` Version = &version`,
+ `}`,
+ ``,
+ }
+
+ if err := os.WriteFile(flagOutFile, []byte(strings.Join(content, "\n")), 0644); err != nil {
+ log.Fatalf("Failed to write output file: %v", err)
+ }
+}