Files
tailscale/version/mkversion/mkversion.go
T
Brad Fitzpatrick 70422c917a version/mkversion: allow supplying the complete version via env vars
mkversion derives the version from git history, which a shallow clone
lacks (the change count silently comes out as 0) and a tree without
.git can't provide at all. If TS_VERSION_LONG is set, InfoFrom now
takes the version from it plus TS_VERSION_GIT_HASH (and the EXTRA/DATE
variants), runs no git, and derives everything else through the same
mkOutput as the git path. The result's Long must reproduce the input
exactly or InfoFrom errors.

Updates tailscale/corp#44945

Change-Id: I9c1247ea5d1c6c52c823c2205447b5c7ee8e778c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-09-29 15:42:05 -07:00

668 lines
23 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package mkversion gets version info from git and provides a bunch of
// differently formatted version strings that get used elsewhere in the build
// system to embed version numbers into binaries.
package mkversion
import (
"bytes"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/mod/modfile"
)
// VersionInfo is version information extracted from a git checkout.
type VersionInfo struct {
// Major is the major version number portion of Short.
Major int
// Minor is the minor version number portion of Short.
Minor int
// Patch is the patch version number portion of Short.
Patch int
// Short is the short version string. See the documentation of version.Short
// for possible values.
Short string
// Long is the long version string. See the documentation for version.Long
// for possible values.
Long string
// GitHash is the git hash of the tailscale.com Go module.
GitHash string
// OtherHash is the git hash of a supplemental git repository, if any. For
// example, the commit of the tailscale-android repository.
OtherHash string
// Xcode is the version string that gets embedded into Xcode builds for the
// Tailscale iOS app and macOS standalone (aka "macsys") app.
//
// It is the same as Short, but with 100 added to the major version number.
// This is because Apple requires monotonically increasing version numbers,
// and very early builds of Tailscale used a single incrementing integer,
// which the Apple interprets as the major version number. When we switched
// to the current scheme, we started the major version number at 100 (v0,
// plus 100) to make the transition.
Xcode string
// XcodeMacOS is the version string that gets embedded into Xcode builds for
// the Tailscale macOS app store app.
//
// This used to be the same as Xcode, but at some point Xcode reverted to
// auto-incrementing build numbers instead of using the version we embedded.
// As a result, we had to alter the version scheme again, and switched to
// GitHash's commit date, in the format "YYYY.DDD.HHMMSS"
XcodeMacOS string
// Winres is the version string that gets embedded into Windows exe
// metadata. It is of the form "x,y,z,0".
Winres string
// Synology is a map of Synology DSM version to the
// Tailscale numeric version that gets embedded in Synology spk
// files.
Synology map[int]int64
// GitDate is the unix timestamp of GitHash's commit date.
GitDate string
// OtherDate is the unix timestamp of OtherHash's commit date, if any.
OtherDate string
// Track is the release track of this build: "stable" if the minor version
// number is even, "unstable" if it's odd.
Track string
// MSIProductCodes is a map of Windows CPU architecture names to UUIDv5
// hashes that uniquely identify the version of the build. These are used in
// the MSI installer logic to uniquely identify particular builds.
MSIProductCodes map[string]string
}
// String returns v's information as shell variable assignments.
func (v VersionInfo) String() string {
f := fmt.Fprintf
var b bytes.Buffer
f(&b, "VERSION_MAJOR=%d\n", v.Major)
f(&b, "VERSION_MINOR=%d\n", v.Minor)
f(&b, "VERSION_PATCH=%d\n", v.Patch)
f(&b, "VERSION_SHORT=%q\n", v.Short)
f(&b, "VERSION_LONG=%q\n", v.Long)
f(&b, "VERSION_GIT_HASH=%q\n", v.GitHash)
f(&b, "VERSION_TRACK=%q\n", v.Track)
if v.OtherHash != "" {
f(&b, "VERSION_EXTRA_HASH=%q\n", v.OtherHash)
f(&b, "VERSION_XCODE=%q\n", v.Xcode)
f(&b, "VERSION_XCODE_MACOS=%q\n", v.XcodeMacOS)
f(&b, "VERSION_WINRES=%q\n", v.Winres)
// Ensure a predictable order for these variables for testing purposes.
for _, k := range []string{"amd64", "arm64", "x86"} {
f(&b, "VERSION_MSIPRODUCT_%s=%q\n", strings.ToUpper(k), v.MSIProductCodes[k])
}
}
return b.String()
}
// Info constructs a VersionInfo from the current working directory and returns
// it, or terminates the process via log.Fatal.
func Info() VersionInfo {
v, err := InfoFrom("")
if err != nil {
log.Fatal(err)
}
return v
}
// Environment variables that let the caller supply the complete version
// instead of having it derived from git history. See [InfoFrom].
const (
envVersionLong = "TS_VERSION_LONG"
envGitHash = "TS_VERSION_GIT_HASH"
envExtraGitHash = "TS_VERSION_EXTRA_GIT_HASH"
envGitDate = "TS_VERSION_GIT_DATE"
envExtraGitDate = "TS_VERSION_EXTRA_GIT_DATE"
envVersionOverride = "TS_VERSION_OVERRIDE"
)
// InfoFrom constructs a VersionInfo from dir and returns it, or an error.
//
// By default the version is derived from the git history of the checkout
// containing dir, which needs the full history back to the most recent
// VERSION.txt change. If dir is a tailscale.com tree without a .git
// directory, only VERSION.txt is consulted and the patch number is set to a
// visible sentinel. In this mode, TS_VERSION_OVERRIDE, if set to
// "major.minor.patch", replaces the version number read from VERSION.txt.
//
// If TS_VERSION_LONG is non-empty, the version instead comes entirely from
// the environment: dir is ignored, no git commands are run, and neither
// VERSION.txt nor go.mod is read. This is for building from a shallow clone
// or a tree with no .git directory when the caller already knows the
// version. The variables are:
//
// - TS_VERSION_LONG (required): the complete version in [version.Long]
// format, e.g. "1.99.5-t8895cec85" or "1.98.2-3-t8895cec85-g15581c318".
// - TS_VERSION_GIT_HASH (required): the full 40-hex-digit hash of the
// tailscale.com commit. Its first 9 digits must match the "-t" part of
// TS_VERSION_LONG.
// - TS_VERSION_EXTRA_GIT_HASH: the full hash of the supplemental
// repository's commit. Required if and only if TS_VERSION_LONG has a
// "-g" part, which its first 9 digits must match.
// - TS_VERSION_GIT_DATE (optional): the tailscale.com commit's date in
// Unix seconds, for [VersionInfo.GitDate].
// - TS_VERSION_EXTRA_GIT_DATE (optional): the supplemental commit's date
// in Unix seconds, for [VersionInfo.OtherDate] and hence
// [VersionInfo.XcodeMacOS].
//
// The remaining fields are derived from those by the same code as the git
// path, and the resulting [VersionInfo.Long] must reproduce TS_VERSION_LONG
// exactly or InfoFrom returns an error. TS_VERSION_OVERRIDE may not be set
// together with TS_VERSION_LONG.
//
// [version.Long]: https://pkg.go.dev/tailscale.com/version#Long
func InfoFrom(dir string) (VersionInfo, error) {
if os.Getenv(envVersionLong) != "" {
return infoFromEnv()
}
runner := dirRunner(dir)
gitRoot, err := runner.output("git", "rev-parse", "--show-toplevel")
if err != nil {
return VersionInfo{}, fmt.Errorf("finding git root: %w", err)
}
runner = dirRunner(gitRoot)
modBs, err := os.ReadFile(filepath.Join(gitRoot, "go.mod"))
if err != nil {
return VersionInfo{}, fmt.Errorf("reading go.mod: %w", err)
}
modPath := modfile.ModulePath(modBs)
if modPath == "" {
return VersionInfo{}, fmt.Errorf("no module path in go.mod")
}
if modPath == "tailscale.com" {
// Invoked in the tailscale.com repo directly, just no further info to
// collect.
v, err := infoFromDir(gitRoot)
if err != nil {
return VersionInfo{}, err
}
return mkOutput(v)
}
// We seem to be in a repo that imports tailscale.com. Find the
// tailscale.com repo and collect additional info from it.
otherHash, err := runner.output("git", "rev-parse", "HEAD")
if err != nil {
return VersionInfo{}, fmt.Errorf("getting git hash: %w", err)
}
otherDate, err := runner.output("git", "log", "-n1", "--format=%ct", "HEAD")
if err != nil {
return VersionInfo{}, fmt.Errorf("getting git date: %w", err)
}
// Note, this mechanism doesn't correctly support go.mod replacements,
// or go workdirs. We only parse out the commit ref from go.mod's
// "require" line, nothing else.
tailscaleRef, err := tailscaleModuleRef(modBs)
if err != nil {
return VersionInfo{}, err
}
v, err := infoFromCache(tailscaleRef, runner)
if err != nil {
return VersionInfo{}, err
}
v.otherHash = otherHash
v.otherDate = otherDate
if !runner.ok("git", "diff-index", "--quiet", "HEAD") {
v.otherHash = v.otherHash + "-dirty"
}
return mkOutput(v)
}
// tailscaleModuleRef returns the git ref of the 'require tailscale.com' line
// in the given go.mod bytes. The ref is either a short commit hash, or a git
// tag.
func tailscaleModuleRef(modBs []byte) (string, error) {
mod, err := modfile.Parse("go.mod", modBs, nil)
if err != nil {
return "", err
}
for _, req := range mod.Require {
if req.Mod.Path != "tailscale.com" {
continue
}
// Get the last - separated part of req.Mod.Version
// (which is the git hash).
if i := strings.LastIndexByte(req.Mod.Version, '-'); i != -1 {
return req.Mod.Version[i+1:], nil
}
// If there are no dashes, the version is a tag.
return req.Mod.Version, nil
}
return "", fmt.Errorf("no require tailscale.com line in go.mod")
}
// longRE matches the version.Long grammar as produced by [mkOutput]:
// major.minor.patch, an optional "-N" change count (stable builds only),
// the "-t" tailscale.com short hash, and an optional "-g" supplemental repo
// short hash.
var longRE = regexp.MustCompile(`^(\d+)\.(\d+)\.(\d+)(?:-(\d+))?-t([0-9a-f]{9})(?:-g([0-9a-f]{9}))?$`)
// fullHashRE matches a complete lowercase hex SHA-1 git object name.
var fullHashRE = regexp.MustCompile(`^[0-9a-f]{40}$`)
// infoFromEnv constructs a VersionInfo from the TS_VERSION_* environment
// variables documented on [InfoFrom], without consulting git or the
// filesystem.
func infoFromEnv() (VersionInfo, error) {
long := os.Getenv(envVersionLong)
if os.Getenv(envVersionOverride) != "" {
return VersionInfo{}, fmt.Errorf("%s may not be set together with %s", envVersionOverride, envVersionLong)
}
m := longRE.FindStringSubmatch(long)
if m == nil {
return VersionInfo{}, fmt.Errorf("%s=%q is not of the form major.minor.patch[-N]-t<hash>[-g<hash>]", envVersionLong, long)
}
var v verInfo
var err error
if v.major, err = strconv.Atoi(m[1]); err != nil {
return VersionInfo{}, fmt.Errorf("%s=%q: bad major version: %w", envVersionLong, long, err)
}
if v.minor, err = strconv.Atoi(m[2]); err != nil {
return VersionInfo{}, fmt.Errorf("%s=%q: bad minor version: %w", envVersionLong, long, err)
}
if v.patch, err = strconv.Atoi(m[3]); err != nil {
return VersionInfo{}, fmt.Errorf("%s=%q: bad patch version: %w", envVersionLong, long, err)
}
if v.minor%2 == 1 {
// Unstable builds put the change count in the patch position and
// never carry a "-N" suffix. mkOutput requires patch == 0 for them
// and fills it in from changeCount.
if m[4] != "" {
return VersionInfo{}, fmt.Errorf("%s=%q: unstable versions do not have a change count suffix", envVersionLong, long)
}
v.changeCount, v.patch = v.patch, 0
} else if m[4] != "" {
if v.changeCount, err = strconv.Atoi(m[4]); err != nil {
return VersionInfo{}, fmt.Errorf("%s=%q: bad change count: %w", envVersionLong, long, err)
}
}
v.hash = os.Getenv(envGitHash)
switch {
case v.hash == "":
return VersionInfo{}, fmt.Errorf("%s is required when %s is set", envGitHash, envVersionLong)
case !fullHashRE.MatchString(v.hash):
return VersionInfo{}, fmt.Errorf("%s=%q is not a full 40-digit lowercase hex git hash", envGitHash, v.hash)
case shortHash(v.hash) != m[5]:
return VersionInfo{}, fmt.Errorf("%s=%q does not match the -t%s in %s=%q", envGitHash, v.hash, m[5], envVersionLong, long)
}
v.otherHash = os.Getenv(envExtraGitHash)
switch {
case m[6] == "" && v.otherHash != "":
return VersionInfo{}, fmt.Errorf("%s is set but %s=%q has no -g part", envExtraGitHash, envVersionLong, long)
case m[6] != "" && v.otherHash == "":
return VersionInfo{}, fmt.Errorf("%s is required because %s=%q has a -g part", envExtraGitHash, envVersionLong, long)
case m[6] != "" && !fullHashRE.MatchString(v.otherHash):
return VersionInfo{}, fmt.Errorf("%s=%q is not a full 40-digit lowercase hex git hash", envExtraGitHash, v.otherHash)
case m[6] != "" && shortHash(v.otherHash) != m[6]:
return VersionInfo{}, fmt.Errorf("%s=%q does not match the -g%s in %s=%q", envExtraGitHash, v.otherHash, m[6], envVersionLong, long)
}
if v.date, err = dateFromEnv(envGitDate); err != nil {
return VersionInfo{}, err
}
if v.otherDate, err = dateFromEnv(envExtraGitDate); err != nil {
return VersionInfo{}, err
}
if v.otherDate != "" && v.otherHash == "" {
return VersionInfo{}, fmt.Errorf("%s is set but %s=%q has no -g part", envExtraGitDate, envVersionLong, long)
}
ret, err := mkOutput(v)
if err != nil {
return VersionInfo{}, err
}
if ret.Long != long {
// The input was well-formed but not something mkOutput can produce,
// such as a "-0" change count or a number with leading zeros.
return VersionInfo{}, fmt.Errorf("%s=%q does not round-trip; derived version is %q", envVersionLong, long, ret.Long)
}
return ret, nil
}
// dateFromEnv returns the value of the named environment variable, which
// must be empty or a non-negative integer of Unix seconds.
func dateFromEnv(name string) (string, error) {
s := os.Getenv(name)
if s == "" {
return "", nil
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil || n < 0 {
return "", fmt.Errorf("%s=%q is not a non-negative integer of Unix seconds", name, s)
}
return s, nil
}
func mkOutput(v verInfo) (VersionInfo, error) {
if override := os.Getenv(envVersionOverride); override != "" {
var err error
v.major, v.minor, v.patch, err = parseVersion(override)
if err != nil {
return VersionInfo{}, fmt.Errorf("failed to parse %s: %w", envVersionOverride, err)
}
}
var changeSuffix string
if v.minor%2 == 1 {
// Odd minor numbers are unstable builds.
if v.patch != 0 {
return VersionInfo{}, fmt.Errorf("unstable release %d.%d.%d has a non-zero patch number, which is not allowed", v.major, v.minor, v.patch)
}
v.patch = v.changeCount
} else if v.changeCount != 0 {
// Even minor numbers are stable builds, but stable builds are
// supposed to have a zero change count. Therefore, we're currently
// describing a commit that's on a release branch, but hasn't been
// tagged as a patch release yet.
//
// We used to change the version number to 0.0.0 in that case, but that
// caused some features to get disabled due to the low version number.
// Instead, add yet another suffix to the version number, with a change
// count.
changeSuffix = "-" + strconv.Itoa(v.changeCount)
}
var hashes string
if v.otherHash != "" {
hashes = "-g" + shortHash(v.otherHash)
}
if v.hash != "" {
hashes = "-t" + shortHash(v.hash) + hashes
}
var track string
if v.minor%2 == 1 {
track = "unstable"
} else {
track = "stable"
}
ret := VersionInfo{
Major: v.major,
Minor: v.minor,
Patch: v.patch,
Short: fmt.Sprintf("%d.%d.%d", v.major, v.minor, v.patch),
Long: fmt.Sprintf("%d.%d.%d%s%s", v.major, v.minor, v.patch, changeSuffix, hashes),
GitHash: fmt.Sprintf("%s", v.hash),
GitDate: fmt.Sprintf("%s", v.date),
Track: track,
Synology: map[int]int64{
// Synology requires that version numbers be in a specific format.
// Builds with version numbers that don't start with "60", "70", or "72" will fail,
// and the full version number must be within int32 range.
// So, we do the following mapping from our Tailscale version to Synology version,
// giving major version three decimal places, minor version three, and patch two.
60: 60*10_000_000 + int64(v.major-1)*1_000_000 + int64(v.minor)*1_000 + int64(v.patch),
70: 70*10_000_000 + int64(v.major-1)*1_000_000 + int64(v.minor)*1_000 + int64(v.patch),
72: 72*10_000_000 + int64(v.major-1)*1_000_000 + int64(v.minor)*1_000 + int64(v.patch),
},
}
if v.otherHash != "" {
ret.OtherHash = fmt.Sprintf("%s", v.otherHash)
// Technically we could populate these fields without the otherHash, but
// these version numbers only make sense when building from Tailscale's
// proprietary repo, so don't clutter open-source-only outputs with
// them.
ret.Xcode = fmt.Sprintf("%d.%d.%d", v.major+100, v.minor, v.patch)
ret.Winres = fmt.Sprintf("%d,%d,%d,0", v.major, v.minor, v.patch)
ret.MSIProductCodes = makeMSIProductCodes(v, track)
}
if v.otherDate != "" {
ret.OtherDate = fmt.Sprintf("%s", v.otherDate)
// Generate a monotonically increasing version number for the macOS app, as
// expected by Apple. We use the date so that it's always increasing (if we
// based it on the actual version number we'd run into issues when doing
// cherrypick stable builds from a release branch after unstable builds from
// HEAD).
otherSec, err := strconv.ParseInt(v.otherDate, 10, 64)
if err != nil {
return VersionInfo{}, fmt.Errorf("Could not parse otherDate %q: %w", v.otherDate, err)
}
otherTime := time.Unix(otherSec, 0).UTC()
// We started to need to do this in 2023, and the last Apple-generated
// incrementing build number was 273. To avoid using up the space, we
// use <year - 1750> as the major version (thus 273.*, 274.* in 2024, etc.),
// so that we're still in the same range. This way if Apple goes back to
// auto-incrementing the number for us, we can go back to it with
// reasonable-looking numbers.
// In May 2024, a build with version number 275 was uploaded to the App Store
// by mistake, causing any 274.* build to be rejected. To address this, +1 was
// added, causing all builds to use the 275.* prefix.
ret.XcodeMacOS = fmt.Sprintf("%d.%d.%d", otherTime.Year()-1750+1, otherTime.YearDay(), otherTime.Hour()*60*60+otherTime.Minute()*60+otherTime.Second())
}
return ret, nil
}
// makeMSIProductCodes produces per-architecture v5 UUIDs derived from the pkgs
// url that would be used for the current version, thus ensuring that product IDs
// are mapped 1:1 to a unique version number.
func makeMSIProductCodes(v verInfo, track string) map[string]string {
urlBase := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%d.%d.%d-", track, v.major, v.minor, v.patch)
result := map[string]string{}
for _, arch := range []string{"amd64", "arm64", "x86"} {
url := fmt.Sprintf("%s%s.msi", urlBase, arch)
curUUID := uuid.NewSHA1(uuid.NameSpaceURL, []byte(url))
// MSI prefers hex digits in UUIDs to be uppercase.
result[arch] = strings.ToUpper(curUUID.String())
}
return result
}
type verInfo struct {
major, minor, patch int
changeCount int
hash string
date string
otherHash string
otherDate string
}
// unknownPatchVersion is the patch version used when the tailscale.com package
// doesn't contain enough version information to derive the correct version.
// Such builds only get used when generating bug reports in an ephemeral working
// environment, so will never be distributed. As such, we use a highly visible
// sentinel patch number.
const unknownPatchVersion = 9999999
func infoFromCache(ref string, runner dirRunner) (verInfo, error) {
tailscaleCache := os.Getenv("TS_MKVERSION_OSS_GIT_CACHE")
if tailscaleCache == "" {
cacheDir, err := os.UserCacheDir()
if err != nil {
return verInfo{}, fmt.Errorf("Getting user cache dir: %w", err)
}
tailscaleCache = filepath.Join(cacheDir, "tailscale-oss")
}
r := dirRunner(tailscaleCache)
if _, err := os.Stat(tailscaleCache); err != nil {
if !runner.ok("git", "clone", "https://github.com/tailscale/tailscale", tailscaleCache) {
return verInfo{}, fmt.Errorf("cloning tailscale.com repo failed")
}
}
if !r.ok("git", "cat-file", "-e", ref) {
if !r.ok("git", "fetch", "origin") {
return verInfo{}, fmt.Errorf("updating OSS repo failed")
}
}
hash, err := r.output("git", "rev-parse", ref)
if err != nil {
return verInfo{}, err
}
date, err := r.output("git", "log", "-n1", "--format=%ct", ref)
if err != nil {
return verInfo{}, err
}
baseHash, err := r.output("git", "rev-list", "--max-count=1", hash, "--", "VERSION.txt")
if err != nil {
return verInfo{}, err
}
s, err := r.output("git", "show", baseHash+":VERSION.txt")
if err != nil {
return verInfo{}, err
}
major, minor, patch, err := parseVersion(s)
if err != nil {
return verInfo{}, err
}
s, err = r.output("git", "rev-list", "--count", hash, "^"+baseHash)
if err != nil {
return verInfo{}, err
}
changeCount, err := strconv.Atoi(s)
if err != nil {
return verInfo{}, fmt.Errorf("infoFromCache: parsing changeCount %q: %w", s, err)
}
return verInfo{
major: major,
minor: minor,
patch: patch,
changeCount: changeCount,
hash: hash,
date: date,
}, nil
}
func infoFromDir(dir string) (verInfo, error) {
r := dirRunner(dir)
gitDir := filepath.Join(dir, ".git")
if _, err := os.Stat(gitDir); err != nil {
// Raw directory fetch, get as much info as we can and make up the rest.
bs, err := os.ReadFile(filepath.Join(dir, "VERSION.txt"))
if err != nil {
return verInfo{}, err
}
major, minor, patch, err := parseVersion(strings.TrimSpace(string(bs)))
return verInfo{
major: major,
minor: minor,
patch: patch,
changeCount: unknownPatchVersion,
}, err
}
hash, err := r.output("git", "rev-parse", "HEAD")
if err != nil {
return verInfo{}, err
}
date, err := r.output("git", "log", "-n1", "--format=%ct", "HEAD")
if err != nil {
return verInfo{}, err
}
baseHash, err := r.output("git", "rev-list", "--max-count=1", hash, "--", "VERSION.txt")
if err != nil {
return verInfo{}, err
}
s, err := r.output("git", "show", baseHash+":VERSION.txt")
if err != nil {
return verInfo{}, err
}
major, minor, patch, err := parseVersion(s)
if err != nil {
return verInfo{}, err
}
s, err = r.output("git", "rev-list", "--count", hash, "^"+baseHash)
if err != nil {
return verInfo{}, err
}
changeCount, err := strconv.Atoi(s)
if err != nil {
return verInfo{}, err
}
return verInfo{
major: major,
minor: minor,
patch: patch,
changeCount: changeCount,
hash: hash,
date: date,
}, nil
}
func parseVersion(s string) (major, minor, patch int, err error) {
fs := strings.Split(strings.TrimSpace(s), ".")
if len(fs) != 3 {
err = fmt.Errorf("parseVersion: parsing %q: wrong number of parts: %d", s, len(fs))
return
}
ints := make([]int, 0, 3)
for _, s := range fs {
var i int
i, err = strconv.Atoi(s)
if err != nil {
err = fmt.Errorf("parseVersion: parsing %q: %w", s, err)
return
}
ints = append(ints, i)
}
return ints[0], ints[1], ints[2], nil
}
func shortHash(hash string) string {
if len(hash) < 9 {
return hash
}
return hash[:9]
}
// dirRunner executes commands in the specified dir.
type dirRunner string
func (r dirRunner) output(prog string, args ...string) (string, error) {
cmd := exec.Command(prog, args...)
// Sometimes, our binaries end up running in a world where
// GO111MODULE=off, because x/tools/go/packages disables Go
// modules on occasion and then runs other Go code. This breaks
// executing "go mod edit", which requires that Go modules be
// enabled.
//
// Since nothing we do here ever wants Go modules to be turned
// off, force it on here so that we can read module data
// regardless of the environment.
cmd.Env = append(os.Environ(), "GO111MODULE=on")
cmd.Dir = string(r)
out, err := cmd.Output()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
return "", fmt.Errorf("running %v: %w, out=%s, err=%s", cmd.Args, err, out, ee.Stderr)
}
return "", fmt.Errorf("running %v: %w, %s", cmd.Args, err, out)
}
return strings.TrimSpace(string(out)), nil
}
func (r dirRunner) ok(prog string, args ...string) bool {
cmd := exec.Command(prog, args...)
cmd.Dir = string(r)
return cmd.Run() == nil
}