v1.0.0: get version from git, or fail gracefully

This commit is contained in:
AJ ONeal 2019-06-20 22:55:24 -06:00
parent e4a0e9576b
commit f104a3155f
8 changed files with 440 additions and 1 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
generated-version.go
# ---> Go # ---> Go
# Binaries for programs and plugins # Binaries for programs and plugins
*.exe *.exe

160
README.md
View File

@ -1,3 +1,161 @@
# git-version.go # git-version.go
Use git tags to add semver to your go package. 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
```

30
examples/basic/README.md Normal file
View File

@ -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`.

3
examples/basic/go.mod Normal file
View File

@ -0,0 +1,3 @@
module example.com/hello
go 1.12

28
examples/basic/main.go Normal file
View File

@ -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!")
}

View File

@ -0,0 +1,8 @@
// build +tools
// This is a dummy package for build tooling
package tools
import (
_ "git.rootprojects.org/root/go-gitver"
)

207
gitver.go Normal file
View File

@ -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 }}"
}
`))

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module git.rootprojects.org/root/go-gitver
go 1.12