diff --git a/.gitignore b/.gitignore index 9a3a8d8..3532227 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +generated-version.go + # ---> Go # Binaries for programs and plugins *.exe diff --git a/README.md b/README.md index b5c5960..2785b04 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,161 @@ # git-version.go -Use git tags to add semver to your go package. \ No newline at end of file +Use git tags to add semver to your go package. + +> Goal: Either use an exact version like v1.0.0 +> or translate the git version like v1.0.0-4-g0000000 +> to a semver like v1.0.1-pre4+g0000000 +> +> Fail gracefully when git repo isn't available. + +# Demo + +```bash +go run git.rootprojects.org/root/go-gitver +``` + +# QuickStart + +Add this to the top of your main file: + +```go +//go:generate go run -mod=vendor git.rootprojects.org/root/go-gitver + +``` + +Add a file that imports go-gitver (for versioning) + +```go +// build +tools + +package example + +import _ "git.rootprojects.org/root/go-gitver" +``` + +Change you build instructions to be something like this: + +```bash +go mod vendor +go generate -mod=vendor ./... +go build -mod=vendor -o example cmd/example/*.go +``` + +You don't have to use `mod vendor`, but I highly recommend it. + +# Options + +``` +version print version and exit +--fail will cause non-zero exit status on failure +``` + +ENVs + +``` +# Alias for --fail +GITVER_FAIL=true +``` + +For example: + +```go +//go:generate go run -mod=vendor git.rootprojects.org/root/go-gitver --fail + +``` + +```bash +go run -mod=vendor git.rootprojects.org/root/go-gitver version +``` + +# Usage + +See `examples/basic` + +1. Create a `tools` package in your project +2. Guard it against regular builds with `// build +tools` +3. Include `_ "git.rootprojects.org/root/go-gitver"` in the imports +4. Declare `var GitRev, GitVersion, GitTimestamp string` in your `package main` +5. Include `//go:generate go run -mod=vendor git.rootprojects.org/root/go-gitver` as well + +`tools/tools.go`: + +```go +// build +tools + +// This is a dummy package for build tooling +package tools + +import ( + _ "git.rootprojects.org/root/go-gitver" +) +``` + +`main.go`: + +```go +//go:generate go run git.rootprojects.org/root/go-gitver --fail + +package main + +import "fmt" + +var ( + GitRev = "0000000" + GitVersion = "v0.0.0-pre0+0000000" + GitTimestamp = "0000-00-00T00:00:00+0000" +) + +func main() { + fmt.Println(GitRev) + fmt.Println(GitVersion) + fmt.Println(GitTimestamp) +} +``` + +If you're using `go mod vendor` (which I highly recommend that you do), +you'd modify the `go:generate` ever so slightly: + +```go +//go:generate go run -mod=vendor git.rootprojects.org/root/go-gitver --fail +``` + +The only reason I didn't do that in the example is that I'd be included +the repository in itself and that would be... weird. + +# Why a tools package? + +> import "git.rootprojects.org/root/go-gitver" is a program, not an importable package + +Having a tools package with a build tag that you don't use is a nice way to add exact +versions of a command package used for tooling to your `go.mod` with `go mod tidy`, +without getting the error above. + +# git: behind the curtain + +These are the commands that are used under the hood to produce the versions. + +Shows the git tag + description. Assumes that you're using the semver format `v1.0.0` for your base tags. + +```bash +git describe --tags --dirty --always +# v1.0.0 +# v1.0.0-1-g0000000 +# v1.0.0-dirty +``` + +Show the commit date (when the commit made it into the current tree). +Internally we use the current date when the working tree is dirty. + +```bash +git show v1.0.0-1-g0000000 --format=%cd --date=format:%Y-%m-%dT%H:%M:%SZ%z --no-patch +# 2010-01-01T20:30:00Z-0600 +# fatal: ambiguous argument 'v1.0.0-1-g0000000-dirty': unknown revision or path not in the working tree. +``` + +Shows the most recent commit. + +```bash +git rev-parse HEAD +# 0000000000000000000000000000000000000000 +``` diff --git a/examples/basic/README.md b/examples/basic/README.md new file mode 100644 index 0000000..9a92e34 --- /dev/null +++ b/examples/basic/README.md @@ -0,0 +1,30 @@ +# Example + +Prints the version or a nice message + +# Build + +Typically the developer would perform these steps +and then commit the results (`go.mod`, `go.sum`, `vendor`). + +However, since this is an example within the project directory, +that seemed a little redundant. + +```bash +go mod tidy +go mod vendor +``` + +These are the instructions that someone cloning the repo might use. + +```bash +go generate -mod=vendor ./... +go build -mod=vendor -o hello *.go +``` + +Note: If the source is distributed in a non-git tarball then +`generated-version.go` will not be output, and whatever +version info is in `package main` will remain as-is. + +If you would prefer the build process to fail (i.e. in a CI/CD pipeline), +you can set the environment variable `GITVER_FAIL=true`. diff --git a/examples/basic/go.mod b/examples/basic/go.mod new file mode 100644 index 0000000..be70eb1 --- /dev/null +++ b/examples/basic/go.mod @@ -0,0 +1,3 @@ +module example.com/hello + +go 1.12 diff --git a/examples/basic/main.go b/examples/basic/main.go new file mode 100644 index 0000000..e898c22 --- /dev/null +++ b/examples/basic/main.go @@ -0,0 +1,28 @@ +//go:generate go run -mod=vendor git.rootprojects.org/root/go-gitver --fail + +package main + +import ( + "flag" + "fmt" +) + +var ( + GitRev = "0000000" + GitVersion = "v0.0.0-pre0+0000000" + GitTimestamp = "0000-00-00T00:00:00+0000" +) + +func main() { + showVersion := flag.Bool("version", false, "Print version and exit") + flag.Parse() + + if *showVersion { + fmt.Println(GitRev) + fmt.Println(GitVersion) + fmt.Println(GitTimestamp) + return + } + + fmt.Println("Hello, World!") +} diff --git a/examples/basic/tools/tools.go b/examples/basic/tools/tools.go new file mode 100644 index 0000000..a48cf2e --- /dev/null +++ b/examples/basic/tools/tools.go @@ -0,0 +1,8 @@ +// build +tools + +// This is a dummy package for build tooling +package tools + +import ( + _ "git.rootprojects.org/root/go-gitver" +) diff --git a/gitver.go b/gitver.go new file mode 100644 index 0000000..645b25e --- /dev/null +++ b/gitver.go @@ -0,0 +1,207 @@ +//go:generate go run -mod=vendor git.rootprojects.org/root/go-gitver + +package main + +import ( + "bytes" + "fmt" + "go/format" + "log" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "text/template" + "time" +) + +var exitCode int +var exactVer *regexp.Regexp +var gitVer *regexp.Regexp +var verFile = "generated-version.go" + +var ( + GitRev = "0000000" + GitVersion = "v0.0.0-pre0+g0000000" + GitTimestamp = "0000-00-00T00:00:00+0000" +) + +func init() { + // exactly vX.Y.Z (go-compatible semver) + exactVer = regexp.MustCompile(`^v\d+\.\d+\.\d+$`) + + // vX.Y.Z-n-g0000000 git post-release, semver prerelease + // vX.Y.Z-dirty git post-release, semver prerelease + gitVer = regexp.MustCompile(`^(v\d+\.\d+)\.(\d+)(-(\d+))?(-(g[0-9a-f]+))?(-(dirty))?`) +} + +func main() { + args := os.Args[1:] + for i := range args { + arg := args[i] + if "-f" == arg || "--fail" == arg { + exitCode = 1 + } else if "-V" == arg || "version" == arg || "-version" == arg || "--version" == arg { + fmt.Println(GitRev) + fmt.Println(GitVersion) + fmt.Println(GitTimestamp) + os.Exit(0) + } + } + if "" != os.Getenv("GITVER_FAIL") && "false" != os.Getenv("GITVER_FAIL") { + exitCode = 1 + } + + desc, err := gitDesc() + if nil != err { + log.Fatalf("Failed to get git version: %s\n", err) + os.Exit(exitCode) + } + rev := gitRev() + ver := semVer(desc) + ts, err := gitTimestamp(desc) + if nil != err { + ts = time.Now() + } + + v := struct { + Timestamp string + Version string + GitRev string + }{ + Timestamp: ts.Format(time.RFC3339), + Version: ver, + GitRev: rev, + } + + // Create or overwrite the go file from template + var buf bytes.Buffer + if err := versionTpl.Execute(&buf, v); nil != err { + panic(err) + } + + // Format + src, err := format.Source(buf.Bytes()) + if nil != err { + panic(err) + } + + // Write to disk (in the Current Working Directory) + f, err := os.Create(verFile) + if nil != err { + panic(err) + } + if _, err := f.Write(src); nil != err { + panic(err) + } + if err := f.Close(); nil != err { + panic(err) + } +} + +func gitDesc() (string, error) { + args := strings.Split("git describe --tags --dirty --always", " ") + cmd := exec.Command(args[0], args[1:]...) + out, err := cmd.CombinedOutput() + if nil != err { + // Don't panic, just carry on + //out = []byte("v0.0.0-0-g0000000") + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func gitRev() string { + args := strings.Split("git rev-parse HEAD", " ") + cmd := exec.Command(args[0], args[1:]...) + out, err := cmd.CombinedOutput() + if nil != err { + fmt.Fprintf(os.Stderr, + "\nUnexpected Error\n\n"+ + "Please open an issue at https://git.rootprojects.org/root/go-gitver/issues/new \n"+ + "Please include the following:\n\n"+ + "Command: %s\n"+ + "Output: %s\n"+ + "Error: %s\n"+ + "\nPlease and Thank You.\n\n", strings.Join(args, " "), out, err) + os.Exit(exitCode) + } + return strings.TrimSpace(string(out)) +} + +func semVer(desc string) string { + if exactVer.MatchString(desc) { + // v1.0.0 + return desc + } + + if !gitVer.MatchString(desc) { + return "" + } + + // (v1.0).(0)(-(1))(-(g0000000))(-(dirty)) + vers := gitVer.FindStringSubmatch(desc) + patch, err := strconv.Atoi(vers[2]) + if nil != err { + fmt.Fprintf(os.Stderr, + "\nUnexpected Error\n\n"+ + "Please open an issue at https://git.rootprojects.org/root/go-gitver/issues/new \n"+ + "Please include the following:\n\n"+ + "git description: %s\n"+ + "RegExp: %#v\n"+ + "Error: %s\n"+ + "\nPlease and Thank You.\n\n", desc, gitVer, err) + os.Exit(exitCode) + } + + // v1.0.1-pre1 + // v1.0.1-pre1+g0000000 + // v1.0.1-pre0+dirty + // v1.0.1-pre0+g0000000-dirty + if "" == vers[4] { + vers[4] = "0" + } + ver := fmt.Sprintf("%s.%d-pre%s", vers[1], patch+1, vers[4]) + if "" != vers[6] || "dirty" == vers[8] { + ver += "+" + if "" != vers[6] { + ver += vers[6] + if "" != vers[8] { + ver += "-" + } + } + ver += vers[8] + } + + return ver +} + +func gitTimestamp(desc string) (time.Time, error) { + args := []string{ + "git", + "show", desc, + "--format=%cd", + "--date=format:%Y-%m-%dT%H:%M:%SZ%z", + "--no-patch", + } + cmd := exec.Command(args[0], args[1:]...) + out, err := cmd.CombinedOutput() + if nil != err { + // a dirty desc was probably used + return time.Time{}, err + } + return time.Parse(time.RFC3339, strings.TrimSpace(string(out))) +} + +var versionTpl = template.Must(template.New("").Parse(`// Code generated by go generate; DO NOT EDIT. +package main + +func init() { + GitRev = "{{ .GitRev }}" + if "" != "{{ .Version }}" { + GitVersion = "{{ .Version }}" + } + GitTimestamp = "{{ .Timestamp }}" +} +`)) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..af5f2ea --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.rootprojects.org/root/go-gitver + +go 1.12