diff --git a/tools/monorel/main.go b/tools/monorel/main.go index a58d305..be396a2 100644 --- a/tools/monorel/main.go +++ b/tools/monorel/main.go @@ -1,17 +1,20 @@ // monorel: Monorepo Release Tool // -// Run from a module directory and pass the paths to each binary's main -// package. Supports both single-binary and multi-binary modules. +// 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: // -// # Single binary (path to the main package, or "." for module root) -// cd cmd/tcpfwd -// monorel . +// # From within a module directory: +// monorel . # single binary at root +// monorel ./cmd/foo ./cmd/bar ./cmd/baz # multiple binaries // -// # Multiple binaries under one module -// cd io/transform/gsheet2csv -// monorel ./cmd/gsheet2csv ./cmd/gsheet2tsv ./cmd/gsheet2env +// # 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: // @@ -28,82 +31,179 @@ import ( "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 dir, e.g. "./cmd/gsheet2csv" or "." + 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, "Run from the module directory (where go.mod lives).") - fmt.Fprintln(os.Stderr, "Use '.' when the module root is itself the main package.") + 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 root") - fmt.Fprintln(os.Stderr, " monorel ./cmd/foo ./cmd/bar ./cmd/baz # multiple binaries") + 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) } - // Must run from the module directory so goreleaser can find go.mod and - // so that .goreleaser.yaml is written next to it. - if _, err := os.Stat("go.mod"); err != nil { - fatalf("no go.mod in current directory; run monorel from the module root") + groups, err := groupByModule(args) + if err != nil { + fatalf("%v", err) } - // 1. Parse binary descriptors from positional args. - bins := parseBinaries(args) + cwd, _ := os.Getwd() + multiModule := len(groups) > 1 - // Guard: binary paths must be strictly inside the module directory, and no - // directory along the path (between the module root and the binary itself, - // inclusive) may contain its own go.mod. - // - // Examples that must be rejected: - // ../other (escapes the module root) - // ./cmd/go.mod (intermediate dir is its own module) - // ./cmd/foo/go.mod (binary dir is its own module) - for _, bin := range bins { - if bin.mainPath == "." { - continue // the module root itself — already checked above + // 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 } - if strings.HasPrefix(bin.mainPath, "../") { - fatalf("%s is outside the module directory", bin.mainPath) - } - // Walk every directory segment from the first child of the module root - // down to the binary directory. - rel := strings.TrimPrefix(bin.mainPath, "./") - parts := strings.Split(rel, "/") - for i := range parts { - dir := "./" + strings.Join(parts[:i+1], "/") - if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { - fatalf("%s has its own go.mod — it is a separate module.\n"+ - " Run monorel from that directory instead:\n"+ - " cd %s && monorel .", dir, dir) + 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}) } - // 2. Module prefix relative to the .git root (e.g., "io/transform/gsheet2csv"). - // This is also the tag prefix, e.g. "io/transform/gsheet2csv/v1.2.3". - prefix := mustRun("git", "rev-parse", "--show-prefix") + 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("run monorel from a module subdirectory, not the repo root") + fatalf("%s appears to be the repo root; the module must be in a subdirectory", modRoot) } - // Project name = last path component (used in checksum filename and release title). prefixParts := strings.Split(prefix, "/") projectName := prefixParts[len(prefixParts)-1] - // 3. Normalised GitHub repo path (e.g., "github.com/therootcompany/golib"). - rawURL := mustRun("git", "remote", "get-url", "origin") + rawURL := mustRunIn(modRoot, "git", "remote", "get-url", "origin") repoPath := normalizeGitURL(rawURL) - // 4. Collect and semver-sort tags matching "/v*". - rawTags := run("git", "tag", "--list", prefix+"/v*") + // 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 != "" { @@ -124,101 +224,63 @@ func main() { } } - // 5. Detect dirty working tree (uncommitted / untracked files under CWD). - isDirty := run("git", "status", "--porcelain", "--", ".") != "" + isDirty := runIn(modRoot, "git", "status", "--porcelain", "--", ".") != "" - // 6. Count commits since latestTag that touch the module directory. var commitCount int if latestTag != "" { - logOut := run("git", "log", "--oneline", latestTag+"..HEAD", "--", ".") + logOut := runIn(modRoot, "git", "log", "--oneline", latestTag+"..HEAD", "--", ".") if logOut != "" { commitCount = len(strings.Split(logOut, "\n")) } } - // 7. Derive version string, full tag, and release flags. version, currentTag, isPreRelease, needsNewTag := computeVersion( prefix, latestTag, commitCount, isDirty, ) - // For release notes prevTag is the last stable tag before the one we're - // releasing. For a pre-release the "stable baseline" is latestTag. prevTag := prevStableTag if isPreRelease { prevTag = latestTag } - // 8. Write .goreleaser.yaml. + // Write .goreleaser.yaml next to go.mod. yamlContent := goreleaserYAML(projectName, bins) - if err := os.WriteFile(".goreleaser.yaml", []byte(yamlContent), 0o644); err != nil { - fatalf("writing .goreleaser.yaml: %v", err) + yamlPath := filepath.Join(modRoot, ".goreleaser.yaml") + if err := os.WriteFile(yamlPath, []byte(yamlContent), 0o644); err != nil { + fatalf("writing %s: %v", yamlPath, err) } - fmt.Fprintln(os.Stderr, "wrote .goreleaser.yaml") + fmt.Fprintf(os.Stderr, "wrote %s\n", yamlPath) - // 9. Emit the release script to stdout. - headSHA := mustRun("git", "rev-parse", "HEAD") - printScript(projectName, bins, version, currentTag, prevTag, repoPath, headSHA, + headSHA := mustRunIn(modRoot, "git", "rev-parse", "HEAD") + printModuleScript(relPath, wrap, projectName, bins, + version, currentTag, prevTag, repoPath, headSHA, isPreRelease, needsNewTag, isDirty) } -// parseBinaries converts positional CLI arguments into binary descriptors. -// -// Each arg is the path to a Go main package, relative to the module directory. -// "." is special-cased: the binary name is taken from the current working -// directory name rather than from ".". -func parseBinaries(args []string) []binary { - cwd, _ := os.Getwd() - bins := make([]binary, 0, len(args)) - for _, arg := range args { - // Normalise to a clean, forward-slash path. - clean := filepath.ToSlash(filepath.Clean(arg)) - - var name string - if clean == "." { - name = filepath.Base(cwd) // e.g., "tcpfwd" from working dir name - } else { - name = filepath.Base(clean) // e.g., "gsheet2csv" - } - - // Restore "./" prefix that filepath.Clean strips, so goreleaser sees - // an explicit relative path (e.g. "./cmd/gsheet2csv" not "cmd/gsheet2csv"). - mainPath := clean - if clean != "." && !strings.HasPrefix(clean, "./") && !strings.HasPrefix(clean, "../") { - mainPath = "./" + clean - } - - bins = append(bins, binary{name: name, mainPath: mainPath}) - } - return bins -} - // ── 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) +// 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 == "" { - // Very first release – default to v0.1.0. return "0.1.0", prefix + "/v0.1.0", false, true } - tagSemver := strings.TrimPrefix(latestTag, prefix+"/") // e.g., "v1.1.0" + tagSemver := strings.TrimPrefix(latestTag, prefix+"/") if commitCount == 0 && !isDirty { - // HEAD is exactly at the tag. version = strings.TrimPrefix(tagSemver, "v") return version, latestTag, false, false } - // Pre-release: bump patch of the base release version. base := strings.TrimPrefix(tagSemver, "v") if idx := strings.Index(base, "-"); idx >= 0 { - base = base[:idx] // drop any existing pre-release label + base = base[:idx] } dp := strings.SplitN(base, ".", 3) patch, _ := strconv.Atoi(dp[2]) @@ -226,7 +288,7 @@ func computeVersion(prefix, latestTag string, commitCount int, isDirty bool) (ve preN := commitCount if preN == 0 { - preN = 1 // dirty with no new commits still needs a label + preN = 1 } preLabel := fmt.Sprintf("pre%d", preN) if isDirty { @@ -235,15 +297,12 @@ func computeVersion(prefix, latestTag string, commitCount int, isDirty bool) (ve version = fmt.Sprintf("%s.%s.%d-%s", dp[0], dp[1], patch, preLabel) currentTag = prefix + "/v" + version - // Only create a new tag for clean (non-dirty) pre-releases. needsNewTag = !isDirty return version, currentTag, true, needsNewTag } // ── Semver helpers ───────────────────────────────────────────────────────── -// semverLess returns true if semver string a < b. -// Handles "vX.Y.Z" and "vX.Y.Z-preN" forms. func semverLess(a, b string) bool { a = strings.TrimPrefix(a, "v") b = strings.TrimPrefix(b, "v") @@ -262,18 +321,15 @@ func semverLess(a, b string) bool { return aP[i] < bP[i] } } - - // Same base version: pre-release < release. if aPre == bPre { return false } if aPre == "" { - return false // a is release → a > b (pre-release) + return false } if bPre == "" { - return true // a is pre-release → a < b (release) + return true } - // Both pre-release: compare numeric suffix of "preN". return preNum(aPre) < preNum(bPre) } @@ -286,7 +342,6 @@ func semverInts(v string) [3]int { return r } -// preNum extracts the numeric value from a pre-release label like "pre3" or "pre3.dirty". func preNum(s string) int { s = strings.TrimPrefix(s, "pre") if idx := strings.IndexAny(s, ".+"); idx >= 0 { @@ -303,16 +358,12 @@ func preNum(s string) int { // goreleaserYAML returns .goreleaser.yaml content for one or more binaries. // -// Design decisions: -// - Uses {{.Env.VERSION}} instead of {{.Version}} everywhere so a prefixed -// monorepo tag (e.g. io/transform/gsheet2csv/v1.2.3) never bleeds into -// artifact filenames. -// - Each binary gets its own build (with id) and its own archive (with ids) -// so cross-platform tarballs are separate per tool. -// - The checksum file is named _VERSION_checksums.txt and -// covers every archive produced in the run. -// - release.disable: true — goreleaser Pro is required to publish with a -// prefixed tag; we use `gh release` in the generated script instead. +// 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) } @@ -324,7 +375,6 @@ func goreleaserYAML(projectName string, bins []binary) string { w("\nversion: 2\n") w("\nbefore:\n hooks:\n - go mod tidy\n - go generate ./...\n") - // ── builds ────────────────────────────────────────────────────────────── w("\nbuilds:\n") for _, bin := range bins { wf(" - id: %s\n", bin.name) @@ -342,7 +392,6 @@ func goreleaserYAML(projectName string, bins []binary) string { w(" goos:\n - linux\n - windows\n - darwin\n") } - // ── archives ──────────────────────────────────────────────────────────── w("\narchives:\n") for _, bin := range bins { wf(" - id: %s\n", bin.name) @@ -362,16 +411,13 @@ func goreleaserYAML(projectName string, bins []binary) string { w(" formats: [zip]\n") } - // ── changelog ─────────────────────────────────────────────────────────── w("\nchangelog:\n sort: asc\n filters:\n exclude:\n") w(" - \"^docs:\"\n - \"^test:\"\n") - // ── checksum ──────────────────────────────────────────────────────────── w("\nchecksum:\n") wf(" name_template: \"%s_{{ .Env.VERSION }}_checksums.txt\"\n", projectName) w(" disable: false\n") - // ── release ───────────────────────────────────────────────────────────── 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") @@ -381,10 +427,12 @@ func goreleaserYAML(projectName string, bins []binary) string { // ── Release script generation ────────────────────────────────────────────── -// printScript writes a numbered, ready-to-review bash release script to stdout. -func printScript( - projectName string, - bins []binary, +// 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, ) { @@ -396,9 +444,15 @@ func printScript( fmt.Println(strings.Repeat("─", max(0, 52-len(title)))) } - line("#!/usr/bin/env bash") - line("# Generated by monorel — review carefully before running!") - line("set -euo pipefail") + 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() @@ -407,14 +461,13 @@ func printScript( line("# A .dirty suffix has been appended to the version below.") } - // Summary comment block. blank() if len(bins) == 1 { line("# %-16s %s", "Binary:", bins[0].name) } else { names := make([]string, len(bins)) - for i, b := range bins { - names[i] = b.name + for i, bin := range bins { + names[i] = bin.name } line("# %-16s %s", "Binaries:", strings.Join(names, ", ")) } @@ -427,27 +480,22 @@ func printScript( } line("# %-16s %s", "Repo:", repoPath) - // Step 1 – env vars. section("Step 1: Environment variables") line("export VERSION=%q", version) line("export GORELEASER_CURRENT_TAG=%q", currentTag) - // Step 2 – create tag (clean pre-releases and first releases only). if needsNewTag { section("Step 2: Create git tag") line("git tag %q", currentTag) line("# To undo: git tag -d %q", currentTag) } - // Step 3 – build. section("Step 3: Build with goreleaser") line("# release.disable=true in .goreleaser.yaml; goreleaser only builds.") line("goreleaser release --clean --skip=validate,announce") - // Step 4 – release notes. section("Step 4: Generate release notes") if prevTag != "" { - // Path-limited: only commits touching files under the module directory. line("RELEASE_NOTES=$(git --no-pager log %q..HEAD \\", prevTag) line(" --pretty=format:'- %%h %%s' -- ./)") } else { @@ -455,9 +503,8 @@ func printScript( line(" --pretty=format:'- %%h %%s' -- ./)") } - // Step 5 – create draft release. section("Step 5: Create draft GitHub release") - tagVersion := currentTag[strings.LastIndex(currentTag, "/")+1:] // strip module prefix + tagVersion := currentTag[strings.LastIndex(currentTag, "/")+1:] title := projectName + " " + tagVersion line("gh release create %q \\", currentTag) line(" --title %q \\", title) @@ -468,7 +515,6 @@ func printScript( line(" --draft \\") line(" --target %q", headSHA) - // Step 6 – upload artifacts. section("Step 6: Upload artifacts") line("gh release upload %q \\", currentTag) for _, bin := range bins { @@ -478,18 +524,18 @@ func printScript( line(" \"./dist/%s_%s_checksums.txt\" \\", projectName, version) line(" --clobber") - // Step 7 – publish. section("Step 7: Publish release (remove draft)") line("gh release edit %q --draft=false", currentTag) + + if wrap { + blank() + fmt.Println(")") + } blank() } // ── Helpers ──────────────────────────────────────────────────────────────── -// normalizeGitURL strips scheme, credentials, and .git suffix from a remote URL. -// -// https://github.com/org/repo.git → github.com/org/repo -// git@github.com:org/repo.git → github.com/org/repo func normalizeGitURL(rawURL string) string { rawURL = strings.TrimSpace(rawURL) rawURL = strings.TrimSuffix(rawURL, ".git") @@ -500,23 +546,26 @@ func normalizeGitURL(rawURL string) string { } return rawURL } - // SCP-style: git@github.com:org/repo if idx := strings.Index(rawURL, "@"); idx >= 0 { rawURL = rawURL[idx+1:] } return strings.ReplaceAll(rawURL, ":", "/") } -func mustRun(name string, args ...string) string { - out, err := exec.Command(name, args...).Output() +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: %v", name, args, err) + fatalf("running %q %v in %s: %v", name, args, dir, err) } return strings.TrimSpace(string(out)) } -func run(name string, args ...string) string { - out, _ := exec.Command(name, args...).CombinedOutput() +func runIn(dir, name string, args ...string) string { + cmd := exec.Command(name, args...) + cmd.Dir = dir + out, _ := cmd.CombinedOutput() return strings.TrimSpace(string(out)) }