golib/tools/monorel/main.go
AJ ONeal 9812f52ee9
fix(monorel): explicit paths, POSIX vars, goreleaser.yaml warning
1. Script paths relative to invoking CWD (not module root):
   - git log pathspec: "-- relPath/" instead of "-- ./"
   - artifact globs:   relPath/dist/ instead of ./dist/
   - goreleaser only:  ( cd "relPath" && goreleaser ... ) inline subshell
   - when relPath==".": all paths use ./ and no cd is emitted
   The outer ( subshell ) wrapper is removed; each command is now
   copy-pasteable from the directory where monorel was invoked.

2. POSIX variable for release notes:
   RELEASE_NOTES= → <project>_release_notes= (no export; goreleaser
   does not need it; multiple modules no longer share the same name).

3. Warn before overwriting .goreleaser.yaml when:
   - the existing file contains {{ .ProjectName }} (stock config), AND
   - the module is a monorepo subdirectory (go.mod not adjacent to .git/)
   The file is still updated; the warning alerts the user that a
   non-monorel config was replaced.
2026-03-01 19:13:49 -07:00

640 lines
21 KiB
Go

// 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"
"go/parser"
"go/token"
"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 <binary-path> [<binary-path>...]")
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()
// 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)
processModule(group, relPath)
}
}
// ── 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
}
}
// checkPackageMain returns an error if dir does not contain a Go main package.
// It only parses the package clause of each file (PackageClauseOnly mode is
// fast: it reads just the first few tokens of every .go file).
func checkPackageMain(dir string) error {
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, dir, nil, parser.PackageClauseOnly)
if err != nil {
return fmt.Errorf("parsing Go files in %s: %w", dir, err)
}
if len(pkgs) == 0 {
return fmt.Errorf("no Go source files in %s", dir)
}
if _, ok := pkgs["main"]; ok {
return nil
}
// Collect non-test package names for the error message.
var names []string
for name := range pkgs {
if !strings.HasSuffix(name, "_test") {
names = append(names, name)
}
}
sort.Strings(names)
if len(names) == 0 {
return fmt.Errorf("no non-test Go source files in %s", dir)
}
return fmt.Errorf("%s is package %q, not a main package", dir, strings.Join(names, ", "))
}
// 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)
}
if err := checkPackageMain(absDir); err != nil {
return nil, err
}
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; it is used in the script for all paths so that the script can
// be run from the directory where monorel was invoked.
func processModule(group *moduleGroup, relPath string) {
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 "<prefix>/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.
// Warn if an existing file uses {{ .ProjectName }} (stock goreleaser config)
// and the module is a monorepo subdirectory (go.mod not adjacent to .git/).
yamlContent := goreleaserYAML(projectName, bins)
yamlPath := filepath.Join(modRoot, ".goreleaser.yaml")
if existing, err := os.ReadFile(yamlPath); err == nil {
hasProjectName := strings.Contains(string(existing), "{{ .ProjectName }}") ||
strings.Contains(string(existing), "{{.ProjectName}}")
gitInfo, gitErr := os.Stat(filepath.Join(modRoot, ".git"))
atGitRoot := gitErr == nil && gitInfo.IsDir()
if hasProjectName && !atGitRoot {
fmt.Fprintf(os.Stderr, "warning: %s: contains {{ .ProjectName }} but module is a monorepo subdirectory;\n", yamlPath)
fmt.Fprintf(os.Stderr, " replacing stock goreleaser config with monorel-generated config.\n")
}
}
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, 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.
//
// All paths in the generated script are relative to relPath so that the
// script can be run from the directory where monorel was invoked:
// - git commands use relPath/ as the pathspec (instead of ./)
// - goreleaser is wrapped in ( cd "relPath" && goreleaser ... ) when needed
// - artifact globs use relPath/dist/ instead of ./dist/
//
// When relPath is "." (monorel was run from the module root), ./ paths are
// used and no cd is required for any command.
func printModuleScript(
relPath string,
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))))
}
// Paths used in the generated script, all relative to the invoking CWD.
var gitPathSpec, distDir string
if relPath == "." {
gitPathSpec = "./"
distDir = "./dist"
} else {
gitPathSpec = relPath + "/"
distDir = relPath + "/dist"
}
// Safe bash variable name for the release-notes capture (no export needed).
notesVar := strings.ReplaceAll(projectName, "-", "_") + "_release_notes"
// Module header.
blank()
rule := strings.Repeat("═", 54)
fmt.Printf("# %s\n", rule)
modLabel := relPath
if modLabel == "." {
modLabel = projectName + " (current directory)"
}
fmt.Printf("# Module: %s\n", modLabel)
fmt.Printf("# %s\n", rule)
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.")
if relPath == "." {
line("goreleaser release --clean --skip=validate,announce")
} else {
line("( cd %q && goreleaser release --clean --skip=validate,announce )", relPath)
}
section("Step 4: Generate release notes")
if prevTag != "" {
line("%s=$(git --no-pager log %q..HEAD \\", notesVar, prevTag)
line(" --pretty=format:'- %%h %%s' -- %s)", gitPathSpec)
} else {
line("%s=$(git --no-pager log \\", notesVar)
line(" --pretty=format:'- %%h %%s' -- %s)", gitPathSpec)
}
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 \"${%s}\" \\", notesVar)
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(" %s/%s_*.tar.gz \\", distDir, bin.name)
line(" %s/%s_*.zip \\", distDir, bin.name)
}
line(" \"%s/%s_%s_checksums.txt\" \\", distDir, projectName, version)
line(" --clobber")
section("Step 7: Publish release (remove draft)")
line("gh release edit %q --draft=false", currentTag)
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)
}