// ============================================================================ simple golang builder (mwx'2026) package main import ( "crypto/sha256" "encoding/hex" "encoding/json" "flag" "io" "io/fs" "os" "os/exec" "os/signal" "path/filepath" "regexp" "runtime" "slices" "strings" "sync" "syscall" "time" "github.com/AlecAivazis/survey/v2" "github.com/fsnotify/fsnotify" ) var version = "1.21.0" var UPDATEURL = "https://gozilla.fhi.mpg.de/gbld" var UPLOADHOST = "root@gozilla" var UPLOADPATH = "/db/www" var oses = []string{"darwin", "linux", "windows"} var arches = []string{"arm64", "amd64"} var skipdirs = []string{"bin", "tmp", "vendor", "node_modules", "testdata"} const KEEPBACKUPS = 20 // backups per file kept in tmp/ var GO string var GOIMPORTS string var mu sync.Mutex // guards 'running' var running *proc var r1 = regexp.MustCompile(`^\.`) // hidden file var r2 = regexp.MustCompile(`^build\.go$`) // own build counter var r3 = regexp.MustCompile(`\.go$`) // go source var r4 = regexp.MustCompile(`(?m)^(\s*var\s+build\s*=\s*")([^"]*)(")`) // build counter var r5 = regexp.MustCompile(`(?im)^\s*var\s+version\s*=\s*"([^"]*)"`) // target version var r6 = regexp.MustCompile(`(?s)import\s*\((.*?)\)`) // import block var r7 = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) // safe build name type opts struct { // ------------------------------------------------------------------------- command line options buildonly bool once bool imports bool exitafter bool postcmd string runargs string } type proc struct { // ------------------------------------------------------ started build, reaped by its own goroutine cmd *exec.Cmd done chan struct{} } func main() { // ========================================================================================== main if len(os.Args) == 1 { // ······································· no arguments: read predefined builds or default os.Args = defaultargs() } opt_b := flag.Bool("b", false, "") opt_1 := flag.Bool("1", false, "") opt_i := flag.Bool("i", false, "") opt_x := flag.Bool("x", false, "") opt_c := flag.String("c", "", "") opt_o := flag.String("o", "", "") opt_a := flag.Bool("a", false, "") opt_v := flag.Bool("v", false, "") opt_h := flag.Bool("h", false, "") opt_u := flag.Bool("u", false, "") flag.Usage = func() { P() info() P() P(Cw("Usage:")) P(Cy(" gbld [-b] [-1] [-i] [-x] [-c ] [-o ] [build name]")) P(Cy(" gbld [-a] [-u]")) P(Cw(" -b build only")) P(Cw(" -1 run only once, no watching (exit code: build error or exit code of the build)")) P(Cw(" -i run goimports before build")) P(Cw(" -x exit after the first successful build, leave the started build running")) P(Cw(" -o exec options")) P(Cw(" -c run command after build")) P(Cw(" -v show version")) P(Cw(" -h show help")) P(Cw(" -a build for all platforms")) P(Cw(" -u upload builds to " + UPLOADHOST)) P() P(Cw(" · if no build name is supplied the current directory name will be used")) P() P(Cw(" · if no option supplied try to read '") + Cy("gbld.menu.json") + Cw("'")) P(Cm(` {`)) P(Cm(` "build": ["-b","gbld"],`)) P(Cm(` "help": ["-h"]`)) P(Cm(` }`)) P() P(Cw(" · if no option is supplied and no '") + Cy("gbld.menu.json") + Cw("' is available")) P(Cw(" the current directory name with ") + Cy("-b -i -1") + Cw(" as options will be used")) P() P(Cw(" · set ") + Cy("GBLD_NO_UPDATE=1") + Cw(" to skip the update check")) P() } flag.Parse() o := opts{buildonly: *opt_b, once: *opt_1, imports: *opt_i, exitafter: *opt_x, postcmd: *opt_c, runargs: *opt_o} if *opt_v { // ·············································································· show version info() os.Exit(0) } if *opt_h { // ····················································································· show help flag.Usage() os.Exit(0) } if os.Getenv("GBLD_NO_UPDATE") == "" { // ······································· check for a newer gbld version checkforupdate(UPDATEURL) } GO = lookup("go") // ······················································· check for the required toolchain if o.imports { GOIMPORTS = lookup("goimports") } name := flag.Arg(0) // ································································ command line arguments if len(name) == 0 { name = dirName() if len(name) == 0 { flag.Usage() os.Exit(1) } } file := name + ".go" if !fileExists(file) { PE("file not found", file) os.Exit(1) } if *opt_a { // ······························································· build for other platforms info() buildall(name) } if *opt_u { // ······································································ upload builds to gozilla info() uploadall(name) } if *opt_a || *opt_u { os.Exit(0) } catchsignals() // ································· stop the started build when gbld itself is asked to quit ok := buildAndRun(name, file, o) // ································································ first build if o.once { // ······················· no watching: report the build result and wait for the started process if !ok { os.Exit(1) } os.Exit(waitRunning()) } watcher, err := fsnotify.NewWatcher() // ····································· watch the tree for .go changes if err != nil { PE("cannot create watcher", err.Error()) os.Exit(1) } defer watcher.Close() if err := watchtree(watcher, "."); err != nil { PE("cannot watch directory", err.Error()) os.Exit(1) } trigger := make(chan struct{}, 1) go func() { for { select { case event, ok := <-watcher.Events: if !ok { return } if event.Op&fsnotify.Create == fsnotify.Create { // new directory: watch it as well if fi, err := os.Stat(event.Name); err == nil && fi.IsDir() { watchtree(watcher, event.Name) continue } } if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename|fsnotify.Remove) == 0 { continue } base := filepath.Base(event.Name) if !r3.MatchString(base) || r1.MatchString(base) || r2.MatchString(base) { continue } select { // never block the watcher, one pending rebuild is enough case trigger <- struct{}{}: default: } case err, ok := <-watcher.Errors: if !ok { return } PE("watcher", err.Error()) } } }() for range trigger { // ································· debounce, then build serially - never in parallel for settled := false; !settled; { select { case <-trigger: case <-time.After(100 * time.Millisecond): settled = true } } buildAndRun(name, file, o) } } func defaultargs() []string { // ------------------------------------------------- arguments if none were supplied c, err := os.ReadFile("gbld.menu.json") if err != nil { return []string{"gbld", "-b", "-i", "-1", dirName()} } var buildjson map[string][]string if err := json.Unmarshal(c, &buildjson); err != nil { PE("cannot parse gbld.menu.json", err.Error()) os.Exit(1) } sel := []string{} for k := range buildjson { sel = append(sel, k) } slices.Sort(sel) // map order is random - keep the menu stable tmp := "" if err := survey.AskOne(&survey.Select{Message: "Select build", Options: sel}, &tmp); err != nil { P(Crb("Interrupted.")) os.Exit(0) } args, ok := buildjson[tmp] if !ok { PE("unknown build", tmp) os.Exit(1) } return append([]string{"gbld"}, args...) } func buildAndRun(name, file string, o opts) bool { // ------------------------- build and (re)start it, ok on success if !o.once { PN("\033[2J\033[1;1H") } info() P(Cc("--- start compiling " + file + " ---")) stopRunning() bumpbuild() if o.imports { runimports() } target := filepath.Join("bin", name+exesuffix(runtime.GOOS)) PF("%s %s\n", Cw("running:"), Cyb("go build "+name)) cmd := exec.Command(GO, "build", "-o", target, ".") out, err := cmd.CombinedOutput() if err != nil { // ·········································································· build failed PN(Cy(string(out))) P(Cr("--- build failed: " + name + " ---")) return false } P(Cb("--- compiling done: " + name + " ---")) // ·········································· build succeeded if len(o.postcmd) > 0 { // ······················································· execute post command params := strings.Fields(o.postcmd) P(Cw(o.postcmd)) post := exec.Command(params[0], params[1:]...) post.Stdout = os.Stdout post.Stdin = os.Stdin post.Stderr = os.Stderr if err := post.Run(); err != nil { PE("post command failed", err.Error()) } } if !o.buildonly { // ······················································· start the new build args := []string{} if len(o.runargs) > 0 { args = strings.Fields(o.runargs) } cmd := exec.Command(target, args...) cmd.Stdout = os.Stdout cmd.Stdin = os.Stdin cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { PE("cannot start "+target, err.Error()) } else { p := &proc{cmd: cmd, done: make(chan struct{})} go func() { cmd.Wait(); close(p.done) }() // reap it as soon as it ends on its own mu.Lock() running = p mu.Unlock() PF("%s %d\n", Cw("process started:"), cmd.Process.Pid) } } P(Cg("--- end building: " + name + " ---")) if o.exitafter { // leaves the started process running on purpose os.Exit(0) } return true } func waitRunning() int { // ------------------------------------- wait for the started build, return its exit code mu.Lock() p := running running = nil mu.Unlock() if p == nil { return 0 } <-p.done if p.cmd.ProcessState == nil { return 0 } return p.cmd.ProcessState.ExitCode() } func bumpbuild() { // ------------------------------------------------------------------ increase build number c, err := os.ReadFile("build.go") if err != nil { return // no build.go, nothing to count } m := r4.FindSubmatch(c) if m == nil { return } old := string(m[2]) b := Atoi(old) + 1 if err := backup("build.go"); err != nil { // never touch a file we could not save first PE("backup of build.go failed", err.Error()) return } if err := os.WriteFile("build.go", r4.ReplaceAll(c, []byte("${1}"+Itoa(b)+"${3}")), 0644); err != nil { PE("cannot write build.go", err.Error()) return } P("build:", old, "->", b) } func runimports() { // ------------------------------------------------- update import blocks with goimports files, err := os.ReadDir(".") if err != nil { PE("cannot read directory", err.Error()) return } for _, f := range files { if f.IsDir() || !strings.HasSuffix(f.Name(), ".go") { continue } fileName := f.Name() output, err := exec.Command(GOIMPORTS, fileName).Output() if err != nil { continue // does not compile yet - the build will report it } m := r6.FindStringSubmatch(string(output)) if len(m) < 2 { continue } origContent, err := os.ReadFile(fileName) if err != nil { continue } updatedContent := ReplaceFirst(r6, string(origContent), SF("import (%s)", m[1])) if string(origContent) == updatedContent { continue } if err := backup(fileName); err != nil { PE("backup of "+fileName+" failed", err.Error()) continue } if err := os.WriteFile(fileName, []byte(updatedContent), 0644); err != nil { PE("cannot write "+fileName, err.Error()) continue } PF("%s %s\n", Cw("imports updated:"), Cmb(fileName)) } } func buildall(name string) { // ------------------------------------------------------ build for all platforms targetversion, err := gettargetversion() if err != nil { PE(err.Error()) os.Exit(1) } P(Ccb("build for all platforms"), Cwb(targetversion)) if err := os.MkdirAll("bin", 0755); err != nil { PE("cannot create bin/", err.Error()) os.Exit(1) } sums := []string{} failed := 0 for _, osys := range oses { for _, arch := range arches { ofile := filepath.Join("bin", artifact(name, targetversion, osys, arch)) cmd := exec.Command(GO, "build", "-o", ofile, ".") cmd.Env = append(os.Environ(), "GOOS="+osys, "GOARCH="+arch) if out, err := cmd.CombinedOutput(); err != nil { PE("build failed: "+ofile, strings.TrimSpace(string(out))) failed++ continue } sum, err := sha256file(ofile) if err != nil { PE("cannot checksum "+ofile, err.Error()) failed++ continue } sums = append(sums, SF("%s %s", sum, filepath.Base(ofile))) P(Cy(ofile), Cw(sum[:16])) } } if failed > 0 { // do not publish a version whose binaries are incomplete PE(SF("%d of %d builds failed", failed, len(oses)*len(arches)), "version.txt not written") os.Exit(1) } if err := os.WriteFile(filepath.Join("bin", "checksums.txt"), []byte(strings.Join(sums, "\n")+"\n"), 0644); err != nil { PE("cannot write bin/checksums.txt", err.Error()) os.Exit(1) } if err := os.WriteFile(filepath.Join("bin", "version.txt"), []byte(targetversion+"\n"), 0644); err != nil { PE("cannot write bin/version.txt", err.Error()) os.Exit(1) } PO("all builds done", targetversion) } func uploadall(name string) { // ------------------------------------------------------- upload builds to gozilla if !r7.MatchString(name) { // the name ends up in a remote shell command PE("invalid build name", name) os.Exit(1) } targetversion, err := gettargetversion() if err != nil { PE(err.Error()) os.Exit(1) } files := []string{} for _, osys := range oses { for _, arch := range arches { ofile := filepath.Join("bin", artifact(name, targetversion, osys, arch)) if fileExists(ofile) { files = append(files, ofile) } } } if len(files) == 0 { PE("no builds found for version "+targetversion, "run 'gbld -a' first") os.Exit(1) } for _, f := range []string{"bin/checksums.txt", "bin/version.txt"} { if !fileExists(f) { PE("missing "+f, "run 'gbld -a' first") os.Exit(1) } files = append(files, f) } P(Ccb("upload builds to "+UPLOADHOST), Cwb(targetversion)) dst := UPLOADPATH + "/" + name if err := run("ssh", UPLOADHOST, SF("mkdir -p '%s' && rm -f '%s'/*_*_*", dst, dst)); err != nil { os.Exit(1) } if err := run(append([]string{"scp"}, append(files, UPLOADHOST+":"+dst)...)...); err != nil { os.Exit(1) } PO("upload done", targetversion) } func run(args ...string) error { // ------------------------------------------------- run a command, report failure P(Cy(strings.Join(args, " "))) out, err := exec.Command(args[0], args[1:]...).CombinedOutput() if err != nil { PE(args[0]+" failed", strings.TrimSpace(string(out))) } return err } func artifact(name, version, osys, arch string) string { // ------------------------------ name of a build artifact return SF("%s_%s_%s_%s%s", name, version, osys, arch, exesuffix(osys)) } func exesuffix(osys string) string { // ------------------------------------------------ windows wants '.exe' if osys == "windows" { return ".exe" } return "" } func sha256file(path string) (string, error) { // --------------------------------------------- checksum of a file f, err := os.Open(path) if err != nil { return "", err } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return "", err } return hex.EncodeToString(h.Sum(nil)), nil } func watchtree(w *fsnotify.Watcher, root string) error { // ------------------------- watch a directory recursively return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil || !d.IsDir() { return nil //nolint - unreadable entries are simply not watched } base := filepath.Base(path) if path != root && (r1.MatchString(base) || slices.Contains(skipdirs, base)) { return filepath.SkipDir } return w.Add(path) }) } func backup(src string) error { // ---------------------------------------------------------------- backup files if err := os.MkdirAll("tmp", 0755); err != nil { return err } sourceFile, err := os.Open(src) if err != nil { return err } defer sourceFile.Close() destFile, err := os.Create(SF("tmp/%s.%s", src, time.Now().Format("20060102150405"))) if err != nil { return err } defer destFile.Close() if _, err := io.Copy(destFile, sourceFile); err != nil { return err } if err := destFile.Sync(); err != nil { return err } prunebackups(src, KEEPBACKUPS) return nil } func prunebackups(src string, keep int) { // ------------------------- keep tmp/ from growing with every rebuild old, err := filepath.Glob(SF("tmp/%s.*", src)) if err != nil || len(old) <= keep { return } slices.Sort(old) // the timestamp suffix sorts chronologically for _, f := range old[:len(old)-keep] { os.Remove(f) } } func gettargetversion() (string, error) { // --------------------------------------------- detect target version files, err := filepath.Glob("*.go") if err != nil { return "", err } for _, file := range files { c, err := os.ReadFile(file) if err != nil { continue } if m := r5.FindSubmatch(c); m != nil { return string(m[1]), nil } } return "", SE(`target version not found, add 'var version = "x.y.z"' to your sources`) } func catchsignals() { // ---------------------------------------------- stop the running build before we exit sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt, syscall.SIGTERM) go func() { <-sig P() stopRunning() os.Exit(0) }() } func stopRunning() { // ------------------------------------------------------------- stop running old process mu.Lock() p := running running = nil mu.Unlock() if p == nil || p.cmd.Process == nil { return } pid := p.cmd.Process.Pid select { case <-p.done: // already gone return default: } p.cmd.Process.Signal(os.Interrupt) select { case <-p.done: P("process stopped:", pid) case <-time.After(1 * time.Second): p.cmd.Process.Kill() <-p.done P("process stopped forcefully:", pid) } } func lookup(what string) string { // ------------------------------------------------- find a required program path, err := exec.LookPath(what) if err != nil { PE(what+" not found", "please install it and make sure it is in your PATH") os.Exit(1) } return path } func fileExists(filename string) bool { // ------------------------------------------------ check if file exists info, err := os.Stat(filename) if err != nil { return false } return !info.IsDir() } func dirName() string { // ------------------------------------------------------- get name of current directory pwd, err := os.Getwd() if err == nil { return filepath.Base(pwd) } return "" } func info() { // ------------------------------------------------------------------------------------- show info PF("%s (%s (%s), toolbox %s, %s)\n", Cwb("gbld - simple golang builder"), Cgb("v"+version), Cgb(build), Cgb("v"+tbversion), Ccb("mwx'2026")) } // ========================================================================================================= END