// monorel: Monorepo Release Tool // // Pass any number of paths to Go main packages. monorel walks up from each // path to find its go.mod (stopping at .git so it never crosses the repo // boundary), groups binaries by their module root, writes a .goreleaser.yaml // for each module, and prints a ready-to-review bash release script. // // Usage: // // # From within a module directory: // monorel . # single binary at root // monorel ./cmd/foo ./cmd/bar ./cmd/baz # multiple binaries // // # From any ancestor directory (e.g. the repo root): // monorel io/transform/gsheet2csv/cmd/gsheet2csv \ // io/transform/gsheet2csv/cmd/gsheet2tsv \ // auth/csvauth/cmd/csvauth // // Install: // // go install github.com/therootcompany/golib/tools/monorel@latest package main import ( "fmt" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" ) // stopMarkers are the directory entries that mark the top of a git repository. // findModuleRoot stops walking upward when it encounters one of these entries // as a DIRECTORY, so it never crosses into a parent repository. // A .git FILE (not a directory) means we are inside a submodule — the real // repository root is further up, so we keep looking. // Adjust this slice if you ever need to search across repository boundaries. var stopMarkers = []string{".git"} // ── Types ────────────────────────────────────────────────────────────────── // binary describes one Go main package to build and release. type binary struct { name string // last path component, e.g. "gsheet2csv" mainPath string // path relative to module root, e.g. "./cmd/gsheet2csv" or "." } // moduleGroup is all the binaries that share one module root. type moduleGroup struct { root string // absolute path to the directory containing go.mod bins []binary } // ── Entry point ──────────────────────────────────────────────────────────── func main() { args := os.Args[1:] if len(args) == 0 { fmt.Fprintln(os.Stderr, "usage: monorel [...]") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Each path points to a Go main package (directory).") fmt.Fprintln(os.Stderr, "The module root (go.mod) is found by walking up from each path,") fmt.Fprintln(os.Stderr, "stopping at .git so it never crosses the repository boundary.") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Examples:") fmt.Fprintln(os.Stderr, " monorel . # single binary at module root") fmt.Fprintln(os.Stderr, " monorel ./cmd/foo ./cmd/bar # multiple binaries, same module") fmt.Fprintln(os.Stderr, " monorel io/transform/gsheet2csv/cmd/foo \\ # from the repo root") fmt.Fprintln(os.Stderr, " auth/csvauth/cmd/bar") os.Exit(2) } groups, err := groupByModule(args) if err != nil { fatalf("%v", err) } cwd, _ := os.Getwd() multiModule := len(groups) > 1 // Emit the bash header exactly once. fmt.Println("#!/usr/bin/env bash") fmt.Println("# Generated by monorel — review carefully before running!") fmt.Println("set -euo pipefail") for _, group := range groups { relPath, _ := filepath.Rel(cwd, group.root) relPath = filepath.ToSlash(relPath) // Wrap in a subshell when the script has to cd somewhere, so multiple // module sections don't interfere with each other. wrap := multiModule || relPath != "." processModule(group, relPath, wrap) } } // ── Module discovery ─────────────────────────────────────────────────────── // findModuleRoot walks upward from absDir looking for a directory that // contains go.mod. It stops (with an error) if it encounters a stopMarker // (default: ".git") before finding go.mod, preventing searches from crossing // into a parent repository. func findModuleRoot(absDir string) (string, error) { dir := absDir for { if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { return dir, nil } for _, stop := range stopMarkers { info, err := os.Stat(filepath.Join(dir, stop)) // A .git FILE means submodule — keep looking up the chain. // Only a .git DIRECTORY marks the true repository root. if err == nil && info.IsDir() { return "", fmt.Errorf( "no go.mod found between %s and the repository root (%s)", absDir, dir) } } parent := filepath.Dir(dir) if parent == dir { return "", fmt.Errorf("no go.mod found above %s", absDir) } dir = parent } } // groupByModule resolves each binary path to an absolute directory, finds its // module root via findModuleRoot, and groups binaries by that root. Groups // are returned in first-occurrence order (preserving the order of args). func groupByModule(args []string) ([]*moduleGroup, error) { groupMap := make(map[string]*moduleGroup) var order []string for _, arg := range args { abs, err := filepath.Abs(arg) if err != nil { return nil, fmt.Errorf("resolving %s: %w", arg, err) } // If the path is a file (not a directory), start from its parent. absDir := abs if info, err := os.Stat(abs); err == nil && !info.IsDir() { absDir = filepath.Dir(abs) } modRoot, err := findModuleRoot(absDir) if err != nil { return nil, err } // mainPath = path from module root to the binary directory. rel, err := filepath.Rel(modRoot, absDir) if err != nil { return nil, fmt.Errorf("computing relative path for %s: %w", arg, err) } rel = filepath.ToSlash(rel) var name, mainPath string if rel == "." { name = filepath.Base(modRoot) // e.g. "tcpfwd" or "gsheet2csv" mainPath = "." } else { name = filepath.Base(rel) // last component mainPath = "./" + rel // e.g. "./cmd/gsheet2csv" } if _, ok := groupMap[modRoot]; !ok { groupMap[modRoot] = &moduleGroup{root: modRoot} order = append(order, modRoot) } groupMap[modRoot].bins = append(groupMap[modRoot].bins, binary{name: name, mainPath: mainPath}) } groups := make([]*moduleGroup, len(order)) for i, root := range order { groups[i] = groupMap[root] } return groups, nil } // ── Per-module processing ────────────────────────────────────────────────── // processModule writes .goreleaser.yaml and emits the release-script section // for one module group. relPath is the path from the caller's CWD to the // module root (used for the cd step in the script). wrap=true wraps the // output in a bash subshell. func processModule(group *moduleGroup, relPath string, wrap bool) { modRoot := group.root bins := group.bins // Module prefix within the repo (e.g. "io/transform/gsheet2csv"). // This is also the git-tag prefix: "io/transform/gsheet2csv/v1.2.3". prefix := mustRunIn(modRoot, "git", "rev-parse", "--show-prefix") prefix = strings.TrimSuffix(prefix, "/") if prefix == "" { fatalf("%s appears to be the repo root; the module must be in a subdirectory", modRoot) } prefixParts := strings.Split(prefix, "/") projectName := prefixParts[len(prefixParts)-1] rawURL := mustRunIn(modRoot, "git", "remote", "get-url", "origin") repoPath := normalizeGitURL(rawURL) // Collect and semver-sort tags matching "/v*". rawTags := runIn(modRoot, "git", "tag", "--list", prefix+"/v*") var tags []string for _, t := range strings.Split(rawTags, "\n") { if t = strings.TrimSpace(t); t != "" { tags = append(tags, t) } } sort.Slice(tags, func(i, j int) bool { vi := strings.TrimPrefix(tags[i], prefix+"/") vj := strings.TrimPrefix(tags[j], prefix+"/") return semverLess(vi, vj) }) var latestTag, prevStableTag string if n := len(tags); n > 0 { latestTag = tags[n-1] if n > 1 { prevStableTag = tags[n-2] } } isDirty := runIn(modRoot, "git", "status", "--porcelain", "--", ".") != "" var commitCount int if latestTag != "" { logOut := runIn(modRoot, "git", "log", "--oneline", latestTag+"..HEAD", "--", ".") if logOut != "" { commitCount = len(strings.Split(logOut, "\n")) } } version, currentTag, isPreRelease, needsNewTag := computeVersion( prefix, latestTag, commitCount, isDirty, ) prevTag := prevStableTag if isPreRelease { prevTag = latestTag } // Write .goreleaser.yaml next to go.mod. yamlContent := goreleaserYAML(projectName, bins) yamlPath := filepath.Join(modRoot, ".goreleaser.yaml") if err := os.WriteFile(yamlPath, []byte(yamlContent), 0o644); err != nil { fatalf("writing %s: %v", yamlPath, err) } fmt.Fprintf(os.Stderr, "wrote %s\n", yamlPath) headSHA := mustRunIn(modRoot, "git", "rev-parse", "HEAD") printModuleScript(relPath, wrap, projectName, bins, version, currentTag, prevTag, repoPath, headSHA, isPreRelease, needsNewTag, isDirty) } // ── Version computation ──────────────────────────────────────────────────── // computeVersion returns (version, fullTag, isPreRelease, needsNewTag). // // Examples: // // At "cmd/tcpfwd/v1.1.0", clean → ("1.1.0", "cmd/tcpfwd/v1.1.0", false, false) // 3 commits past v1.1.0, clean → ("1.1.1-pre3", "cmd/tcpfwd/v1.1.1-pre3", true, true) // dirty, 0 new commits → ("1.1.1-pre1.dirty", "cmd/tcpfwd/v1.1.1-pre1.dirty", true, false) func computeVersion(prefix, latestTag string, commitCount int, isDirty bool) (version, currentTag string, isPreRelease, needsNewTag bool) { if latestTag == "" { return "0.1.0", prefix + "/v0.1.0", false, true } tagSemver := strings.TrimPrefix(latestTag, prefix+"/") if commitCount == 0 && !isDirty { version = strings.TrimPrefix(tagSemver, "v") return version, latestTag, false, false } base := strings.TrimPrefix(tagSemver, "v") if idx := strings.Index(base, "-"); idx >= 0 { base = base[:idx] } dp := strings.SplitN(base, ".", 3) patch, _ := strconv.Atoi(dp[2]) patch++ preN := commitCount if preN == 0 { preN = 1 } preLabel := fmt.Sprintf("pre%d", preN) if isDirty { preLabel += ".dirty" } version = fmt.Sprintf("%s.%s.%d-%s", dp[0], dp[1], patch, preLabel) currentTag = prefix + "/v" + version needsNewTag = !isDirty return version, currentTag, true, needsNewTag } // ── Semver helpers ───────────────────────────────────────────────────────── func semverLess(a, b string) bool { a = strings.TrimPrefix(a, "v") b = strings.TrimPrefix(b, "v") var aPre, bPre string if idx := strings.Index(a, "-"); idx >= 0 { aPre, a = a[idx+1:], a[:idx] } if idx := strings.Index(b, "-"); idx >= 0 { bPre, b = b[idx+1:], b[:idx] } aP, bP := semverInts(a), semverInts(b) for i := range aP { if aP[i] != bP[i] { return aP[i] < bP[i] } } if aPre == bPre { return false } if aPre == "" { return false } if bPre == "" { return true } return preNum(aPre) < preNum(bPre) } func semverInts(v string) [3]int { p := strings.SplitN(v, ".", 3) var r [3]int for i := 0; i < len(p) && i < 3; i++ { r[i], _ = strconv.Atoi(p[i]) } return r } func preNum(s string) int { s = strings.TrimPrefix(s, "pre") if idx := strings.IndexAny(s, ".+"); idx >= 0 { s = s[:idx] } n, err := strconv.Atoi(s) if err != nil { return -1 } return n } // ── goreleaser YAML generation ───────────────────────────────────────────── // goreleaserYAML returns .goreleaser.yaml content for one or more binaries. // // Key decisions: // - {{.Env.VERSION}} is used everywhere so the prefixed monorepo tag never // appears in artifact filenames. // - Each binary gets its own build (id) and archive (ids) for separate tarballs. // - release.disable: true — we use `gh release` instead (goreleaser Pro // would be needed to publish via a prefixed tag). func goreleaserYAML(projectName string, bins []binary) string { var b strings.Builder w := func(s string) { b.WriteString(s) } wf := func(format string, args ...any) { fmt.Fprintf(&b, format, args...) } w("# yaml-language-server: $schema=https://goreleaser.com/static/schema.json\n") w("# vim: set ts=2 sw=2 tw=0 fo=cnqoj\n") w("# Generated by monorel (github.com/therootcompany/golib/tools/monorel)\n") w("\nversion: 2\n") w("\nbefore:\n hooks:\n - go mod tidy\n - go generate ./...\n") w("\nbuilds:\n") for _, bin := range bins { wf(" - id: %s\n", bin.name) wf(" binary: %s\n", bin.name) if bin.mainPath != "." { wf(" main: %s\n", bin.mainPath) } w(" env:\n - CGO_ENABLED=0\n") w(" ldflags:\n") w(" - -s -w" + " -X main.version={{.Env.VERSION}}" + " -X main.commit={{.Commit}}" + " -X main.date={{.Date}}" + " -X main.builtBy=goreleaser\n") w(" goos:\n - linux\n - windows\n - darwin\n") } w("\narchives:\n") for _, bin := range bins { wf(" - id: %s\n", bin.name) wf(" ids: [%s]\n", bin.name) w(" formats: [tar.gz]\n") w(" # name_template uses VERSION env var so the prefixed monorepo tag\n") w(" # doesn't appear in archive filenames.\n") w(" name_template: >-\n") wf(" %s_{{ .Env.VERSION }}_\n", bin.name) w(" {{- title .Os }}_\n") w(" {{- if eq .Arch \"amd64\" }}x86_64\n") w(" {{- else if eq .Arch \"386\" }}i386\n") w(" {{- else }}{{ .Arch }}{{ end }}\n") w(" {{- if .Arm }}v{{ .Arm }}{{ end }}\n") w(" format_overrides:\n") w(" - goos: windows\n") w(" formats: [zip]\n") } w("\nchangelog:\n sort: asc\n filters:\n exclude:\n") w(" - \"^docs:\"\n - \"^test:\"\n") w("\nchecksum:\n") wf(" name_template: \"%s_{{ .Env.VERSION }}_checksums.txt\"\n", projectName) w(" disable: false\n") w("\n# Release is disabled: goreleaser Pro is required to publish with a\n") w("# prefixed monorepo tag. We use 'gh release' instead (see release script).\n") w("release:\n disable: true\n") return b.String() } // ── Release script generation ────────────────────────────────────────────── // printModuleScript emits one module's release steps to stdout. // If wrap=true the output is enclosed in a bash subshell ( ... ) with a cd // at the top, so multiple modules in one script don't interfere. func printModuleScript( relPath string, wrap bool, projectName string, bins []binary, version, currentTag, prevTag, repoPath, headSHA string, isPreRelease, needsNewTag, isDirty bool, ) { line := func(format string, args ...any) { fmt.Printf(format+"\n", args...) } blank := func() { fmt.Println() } section := func(title string) { blank() fmt.Printf("# ── %s ", title) fmt.Println(strings.Repeat("─", max(0, 52-len(title)))) } if wrap { blank() rule := strings.Repeat("═", 54) fmt.Printf("# %s\n", rule) fmt.Printf("# Module: %s\n", relPath) fmt.Printf("# %s\n", rule) fmt.Println("(") fmt.Printf("cd %q\n", relPath) } if isDirty { blank() line("# ⚠ WARNING: working tree has uncommitted changes.") line("# Commit or stash them before releasing for a reproducible build.") line("# A .dirty suffix has been appended to the version below.") } blank() if len(bins) == 1 { line("# %-16s %s", "Binary:", bins[0].name) } else { names := make([]string, len(bins)) for i, bin := range bins { names[i] = bin.name } line("# %-16s %s", "Binaries:", strings.Join(names, ", ")) } line("# %-16s %s", "VERSION:", version) line("# %-16s %s", "Current tag:", currentTag) if prevTag != "" { line("# %-16s %s", "Previous tag:", prevTag) } else { line("# %-16s (none — first release)", "Previous tag:") } line("# %-16s %s", "Repo:", repoPath) section("Step 1: Environment variables") line("export VERSION=%q", version) line("export GORELEASER_CURRENT_TAG=%q", currentTag) if needsNewTag { section("Step 2: Create git tag") line("git tag %q", currentTag) line("# To undo: git tag -d %q", currentTag) } section("Step 3: Build with goreleaser") line("# release.disable=true in .goreleaser.yaml; goreleaser only builds.") line("goreleaser release --clean --skip=validate,announce") section("Step 4: Generate release notes") if prevTag != "" { line("RELEASE_NOTES=$(git --no-pager log %q..HEAD \\", prevTag) line(" --pretty=format:'- %%h %%s' -- ./)") } else { line("RELEASE_NOTES=$(git --no-pager log \\") line(" --pretty=format:'- %%h %%s' -- ./)") } section("Step 5: Create draft GitHub release") tagVersion := currentTag[strings.LastIndex(currentTag, "/")+1:] title := projectName + " " + tagVersion line("gh release create %q \\", currentTag) line(" --title %q \\", title) line(" --notes \"${RELEASE_NOTES}\" \\") if isPreRelease { line(" --prerelease \\") } line(" --draft \\") line(" --target %q", headSHA) section("Step 6: Upload artifacts") line("gh release upload %q \\", currentTag) for _, bin := range bins { line(" ./dist/%s_*.tar.gz \\", bin.name) line(" ./dist/%s_*.zip \\", bin.name) } line(" \"./dist/%s_%s_checksums.txt\" \\", projectName, version) line(" --clobber") section("Step 7: Publish release (remove draft)") line("gh release edit %q --draft=false", currentTag) if wrap { blank() fmt.Println(")") } blank() } // ── Helpers ──────────────────────────────────────────────────────────────── func normalizeGitURL(rawURL string) string { rawURL = strings.TrimSpace(rawURL) rawURL = strings.TrimSuffix(rawURL, ".git") if idx := strings.Index(rawURL, "://"); idx >= 0 { rawURL = rawURL[idx+3:] if idx2 := strings.Index(rawURL, "@"); idx2 >= 0 { rawURL = rawURL[idx2+1:] } return rawURL } if idx := strings.Index(rawURL, "@"); idx >= 0 { rawURL = rawURL[idx+1:] } return strings.ReplaceAll(rawURL, ":", "/") } func mustRunIn(dir, name string, args ...string) string { cmd := exec.Command(name, args...) cmd.Dir = dir out, err := cmd.Output() if err != nil { fatalf("running %q %v in %s: %v", name, args, dir, err) } return strings.TrimSpace(string(out)) } func runIn(dir, name string, args ...string) string { cmd := exec.Command(name, args...) cmd.Dir = dir out, _ := cmd.CombinedOutput() return strings.TrimSpace(string(out)) } func fatalf(format string, args ...any) { fmt.Fprintf(os.Stderr, "monorel: error: "+format+"\n", args...) os.Exit(1) }