Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 250 additions & 0 deletions builder/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"strconv"
"strings"
"sync"
"time"

"github.com/gofrs/flock"
"github.com/tinygo-org/tinygo/compileopts"
Expand All @@ -34,6 +35,8 @@ import (
"github.com/tinygo-org/tinygo/loader"
"github.com/tinygo-org/tinygo/stacksize"
"github.com/tinygo-org/tinygo/transform"
"golang.org/x/mod/module"
"golang.org/x/mod/semver"
"tinygo.org/x/go-llvm"
)

Expand Down Expand Up @@ -249,6 +252,26 @@ func Build(pkgName, outpath, tmpdir string, config *compileopts.Config) (BuildRe
return result, err
}

// Embed module build information so runtime/debug.ReadBuildInfo() works,
// mirroring what the standard `go build` toolchain does. We fill
// runtime/debug.modinfo (a plain string global) from the module info that
// `go list` already reported for the loaded packages; runtime/debug parses
// it back into a *BuildInfo. An explicit -ldflags="-X runtime/debug.modinfo=..."
// takes precedence. This is skipped in GOPATH mode (no module info) and when
// the main package isn't in a module.
if _, overridden := globalValues["runtime/debug"]["modinfo"]; !overridden {
mi, err := moduleBuildInfo(lprogram, config.Options.BuildVCS)
if err != nil {
return result, err
}
if mi != "" {
if globalValues["runtime/debug"] == nil {
globalValues["runtime/debug"] = map[string]string{}
}
globalValues["runtime/debug"]["modinfo"] = mi
}
}

// Store which filesystem paths map to which package name.
result.PackagePathMap = make(map[string]string, len(lprogram.Packages))
for _, pkg := range lprogram.Sorted() {
Expand Down Expand Up @@ -1603,3 +1626,230 @@ func b2u8(b bool) uint8 {
}
return 0
}

// moduleBuildInfo constructs the module build-info string embedded into the
// runtime/debug.modinfo global, in the same textual format that
// runtime/debug.BuildInfo.String() produces (minus the leading "go" line, which
// runtime/debug supplies from runtime.Version()). runtime/debug.ReadBuildInfo
// parses it back into a *BuildInfo, so `go build`-style version reporting works
// under TinyGo without -ldflags. It returns "" when there is no module
// information to embed (e.g. GOPATH mode, or a main package outside any module).
//
// The layout is the reverse of runtime/debug.ParseBuildInfo:
//
// path\t<main package import path>\n
// mod\t<main module path>\t<version>\t<sum>\n
// dep\t<module path>\t<version>\t<sum>\n (one per contributing module, sorted)
//
// The main module version is reported as "(devel)" for a local checkout, as the
// go toolchain does; VCS-derived pseudo-version stamping is a separate follow-up.
// buildVCS selects whether the vcs.* settings and a VCS-derived version are
// stamped in: "false" skips it, "true" and "auto" (and "", the zero value, for
// callers that don't set it) stamp when a usable repository is found. This
// mirrors the go toolchain's -buildvcs, including its reason for existing:
// stamping shells out to git on every build, which a caller may not want to
// pay for.
func moduleBuildInfo(lprogram *loader.Program, buildVCS string) (string, error) {
main := lprogram.MainPkg()
if main == nil || main.Module.Path == "" {
return "", nil // GOPATH mode or no module: nothing to embed.
}

// Collect the distinct non-main modules that contributed packages to the
// build. As in the go toolchain, a module is listed if any of its packages
// are part of the build graph.
depVersions := make(map[string]string) // module path -> version
for _, pkg := range lprogram.Sorted() {
m := pkg.Module
if m.Path == "" || m.Main || m.Path == main.Module.Path {
continue
}
depVersions[m.Path] = m.Version
}
deps := make([]string, 0, len(depVersions))
for path := range depVersions {
deps = append(deps, path)
}
sort.Strings(deps)

// Derive the main module version. `go list` leaves it empty for a local
// checkout, so fall back to VCS stamping (as `go build` does): an exact tag
// on HEAD, otherwise a pseudo-version. This also yields the vcs.* build
// settings appended below. If VCS info isn't available, use "(devel)".
mainVersion := main.Module.Version
var vcsSettings string
if mainVersion == "" {
if buildVCS == "false" {
// Don't touch the repository at all: no git subprocesses run.
mainVersion = "(devel)"
} else {
v, s, gitErr := gitVCSStamp(main.Module.Dir, main.Module.Path)
switch {
case v != "":
mainVersion, vcsSettings = v, s
case buildVCS == "true":
// -buildvcs=true means the stamp was demanded, so failing to
// produce one is an error rather than a silent fallback. This
// matches the go toolchain.
reason := "not a git work tree, the module is not at the repository root, or git is unavailable"
if gitErr != nil {
reason = gitErr.Error()
}
return "", fmt.Errorf("error obtaining VCS status for %s: %s\n"+
"\tUse -buildvcs=false to disable VCS stamping.", main.Module.Dir, reason)
default:
mainVersion = "(devel)"
}
}
}

var b strings.Builder
b.WriteString("path\t")
b.WriteString(main.ImportPath)
b.WriteByte('\n')
b.WriteString("mod\t")
b.WriteString(main.Module.Path)
b.WriteByte('\t')
b.WriteString(mainVersion)
b.WriteString("\t\n") // trailing tab leaves the checksum column empty
for _, path := range deps {
b.WriteString("dep\t")
b.WriteString(path)
b.WriteByte('\t')
b.WriteString(depVersions[path])
b.WriteString("\t\n") // go list -json carries no checksum; leave it empty
}
// Build settings (vcs.*) come after the module lines, matching
// runtime/debug.BuildInfo.String().
b.WriteString(vcsSettings)
return b.String(), nil
}

// gitVCSStamp derives the main-module version and the vcs.* build settings from
// the git checkout at dir, mirroring what the standard `go build` toolchain
// records under -buildvcs. modPath is the main module's path, used to reject a
// version that belongs to some other module.
//
// It returns ("", "", nil) when there is simply no stamp to make — dir is not a
// git work tree, or git is unavailable — so the caller falls back to "(devel)".
// The error is non-nil only when git itself failed in a way worth reporting,
// which -buildvcs=true turns into a hard failure.
func gitVCSStamp(dir, modPath string) (version, settings string, gitErr error) {
if dir == "" {
return "", "", nil
}
// Keep the first real git failure, so -buildvcs=true can say what actually
// went wrong. "not a work tree" and a refusal over safe.directory or file
// permissions are very different problems and should not share a message.
var firstErr error
git := func(args ...string) (string, bool) {
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
var stderr strings.Builder
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
if firstErr == nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
firstErr = fmt.Errorf("git %s: %s", strings.Join(args, " "), msg)
}
return "", false
}
return strings.TrimSpace(string(out)), true
}
if out, ok := git("rev-parse", "--is-inside-work-tree"); !ok || out != "true" {
return "", "", firstErr
}
// The tags below are searched from the module directory, so they can belong
// to a parent repository. Only stamp when the module is itself the thing the
// repository is versioning; otherwise a monorepo, or a nested module, gets a
// version that is not its own. The go toolchain makes the same check.
root, ok := git("rev-parse", "--show-toplevel")
if !ok {
return "", "", firstErr
}
if absDir, err := filepath.Abs(dir); err == nil {
if absRoot, err := filepath.Abs(root); err == nil && absDir != absRoot {
return "", "", nil // module is not at the repository root; no stamp
}
}
rev, ok := git("rev-parse", "HEAD")
if !ok || rev == "" {
return "", "", firstErr
}

// Commit time (Unix seconds → UTC), used in the pseudo-version and vcs.time.
// A missing or unparsable time is a stamp failure rather than something to
// paper over: module.PseudoVersion would otherwise encode the zero time as
// 00010101000000 and produce a version that looks real but is not.
var commitTime time.Time
s, ok := git("show", "-s", "--format=%ct", "HEAD")
if !ok {
return "", "", firstErr
}
sec, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return "", "", fmt.Errorf("git show -s --format=%%ct: unparsable commit time %q", s)
}
commitTime = time.Unix(sec, 0).UTC()

// The tree is "modified" if there are any uncommitted changes (tracked or
// untracked), as reported by `git status --porcelain` — same as `go build`.
modified := false
if status, ok := git("status", "--porcelain"); ok && status != "" {
modified = true
}

// Version: an exact semver tag pointing at HEAD, else a Go-style
// pseudo-version based on the most recent reachable tag.
if tags, ok := git("tag", "--points-at", "HEAD"); ok {
for _, t := range strings.Fields(tags) {
if semver.IsValid(t) && semver.Canonical(t) == t {
version = t
break
}
}
}
if version == "" {
older := ""
if base, ok := git("describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"); ok && semver.IsValid(base) {
older = base
}
short := rev
if len(short) > 12 {
short = short[:12]
}
version = module.PseudoVersion(semver.Major(older), older, commitTime, short)
}

// The version came from whatever tags git found, which says nothing about
// the module path. Reject the mismatch that produces: a v1 tag on a module
// whose path ends in /v2, and the reverse.
//
// This deliberately checks only the major version, not the whole path.
// module.Check would also reject a path with no dot in its first element,
// which is a perfectly ordinary thing for a module that is never published,
// and stripping the stamp from those would be a regression.
if _, pathMajor, ok := module.SplitPathVersion(modPath); ok {
if err := module.CheckPathMajor(version, pathMajor); err != nil {
return "", "", nil // not our version to claim; fall back to (devel)
}
}

// A modified work tree does not describe the tagged commit any more, so say
// so, exactly as `go build` does.
if modified {
version += "+dirty"
}

var sb strings.Builder
sb.WriteString("build\tvcs=git\n")
sb.WriteString("build\tvcs.revision=" + rev + "\n")
// RFC3339Nano, matching cmd/go. %ct only gives whole seconds, so nothing is
// lost either way, but the format should be the one Go readers expect.
sb.WriteString("build\tvcs.time=" + commitTime.Format(time.RFC3339Nano) + "\n")
sb.WriteString("build\tvcs.modified=" + strconv.FormatBool(modified) + "\n")
return version, sb.String(), nil
}
10 changes: 9 additions & 1 deletion compileopts/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ var (
validPrintSizeOptions = []string{"none", "short", "full", "html"}
validPanicStrategyOptions = []string{"print", "trap"}
validOptOptions = []string{"none", "0", "1", "2", "s", "z"}
validBuildVCSOptions = []string{"auto", "true", "false"}
)

// Options contains extra options to give to the compiler. These options are
Expand Down Expand Up @@ -62,7 +63,8 @@ type Options struct {
WITPackage string // pass through to wasm-tools component embed invocation
WITWorld string // pass through to wasm-tools component embed -w option
ExtLDFlags []string
GoCompatibility bool // enable to check for Go version compatibility
GoCompatibility bool // enable to check for Go version compatibility
BuildVCS string // -buildvcs: "auto" (default), "true" or "false"
}

// Verify performs a validation on the given options, raising an error if options are not valid.
Expand Down Expand Up @@ -126,5 +128,11 @@ func (o *Options) Verify() error {
}
}

if o.BuildVCS != "" {
if !slices.Contains(validBuildVCSOptions, o.BuildVCS) {
return fmt.Errorf("invalid -buildvcs=%s: valid values are %s", o.BuildVCS, strings.Join(validBuildVCSOptions, ", "))
}
}

return nil
}
26 changes: 26 additions & 0 deletions compileopts/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ func TestVerifyOptions(t *testing.T) {
expectedSchedulerError := errors.New(`invalid scheduler option 'incorrect': valid values are none, tasks, asyncify, threads, cores`)
expectedPrintSizeError := errors.New(`invalid size option 'incorrect': valid values are none, short, full, html`)
expectedPanicStrategyError := errors.New(`invalid panic option 'incorrect': valid values are print, trap`)
expectedBuildVCSError := errors.New(`invalid -buildvcs=incorrect: valid values are auto, true, false`)

testCases := []struct {
name string
Expand Down Expand Up @@ -117,6 +118,31 @@ func TestVerifyOptions(t *testing.T) {
PanicStrategy: "trap",
},
},
{
name: "InvalidBuildVCSOption",
opts: compileopts.Options{
BuildVCS: "incorrect",
},
expectedError: expectedBuildVCSError,
},
{
name: "BuildVCSOptionAuto",
opts: compileopts.Options{
BuildVCS: "auto",
},
},
{
name: "BuildVCSOptionTrue",
opts: compileopts.Options{
BuildVCS: "true",
},
},
{
name: "BuildVCSOptionFalse",
opts: compileopts.Options{
BuildVCS: "false",
},
},
}

for _, tc := range testCases {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ require (
go.bug.st/serial v1.8.0
go.bytecodealliance.org v0.6.2
go.bytecodealliance.org/cm v0.2.2
golang.org/x/mod v0.37.0
golang.org/x/net v0.56.0
golang.org/x/sys v0.47.0
golang.org/x/tools v0.47.0
Expand Down Expand Up @@ -47,6 +48,5 @@ require (
github.com/spf13/afero v1.11.0 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/urfave/cli/v3 v3.0.0-beta1 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/text v0.38.0 // indirect
)
1 change: 1 addition & 0 deletions loader/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type PackageJSON struct {
Dir string
GoMod string
GoVersion string
Version string // module version, e.g. "v1.2.3" (empty for the main module)
}

// Source files
Expand Down
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1775,6 +1775,7 @@ func main() {
flag.Var(&tags, "tags", "a space-separated list of extra build tags")
target := flag.String("target", "", "chip/board name or JSON target specification file")
buildMode := flag.String("buildmode", "", "build mode to use (default, c-shared, wasi-legacy)")
buildVCS := flag.String("buildvcs", "auto", "whether to stamp version control information (true, false, auto)")
var stackSize uint64
flag.Func("stack-size", "goroutine stack size (if unknown at compile time)", func(s string) error {
size, err := bytesize.Parse(s)
Expand Down Expand Up @@ -1901,6 +1902,7 @@ func main() {
GOMIPS: goenv.Get("GOMIPS"),
Target: *target,
BuildMode: *buildMode,
BuildVCS: *buildVCS,
StackSize: stackSize,
Opt: *opt,
GC: *gc,
Expand Down
Loading
Loading