golib/tools/monorel/main.go
AJ ONeal 12c025e5e2
feat(monorel): require binary-path args; support multi-binary modules
The tool now requires at least one positional argument — the path(s) to
the Go main package(s) to build — and must be run from the module root
(the directory containing go.mod).

  # single binary (module root is the main package)
  monorel .

  # multiple binaries under one module
  monorel ./cmd/gsheet2csv ./cmd/gsheet2tsv ./cmd/gsheet2env

Changes:
- Add `binary` struct {name, mainPath} and `parseBinaries()`
  - "." is special-cased: binary name is taken from the CWD, not "."
  - filepath.Clean's "./"-stripping is undone so goreleaser sees an
    explicit relative path (./cmd/foo not cmd/foo)
- `goreleaserYAML` now takes `projectName + []binary`
  - Each binary gets its own `builds` entry (with `id:` and `main:`)
    and its own `archives` entry (with `ids:` to link it to the build)
  - `main:` is omitted when mainPath is "." (goreleaser default)
  - Checksum is named <projectName>_VERSION_checksums.txt
- `printScript` takes `projectName + []binary`
  - Summary line says "Binaries:" (plural) when more than one
  - Upload step globs tar.gz + zip for every binary, then the checksum
- Require go.mod in CWD; error out with usage message when no args given

Also regenerates cmd/tcpfwd/.goreleaser.yaml via the new code path
(adds `id: tcpfwd` to builds/archives; no functional change otherwise).
2026-03-01 19:13:45 -07:00

498 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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.
//
// Usage:
//
// # Single binary (path to the main package, or "." for module root)
// cd cmd/tcpfwd
// monorel .
//
// # Multiple binaries under one module
// cd io/transform/gsheet2csv
// monorel ./cmd/gsheet2csv ./cmd/gsheet2tsv ./cmd/gsheet2env
//
// Install:
//
// go install github.com/therootcompany/golib/tools/monorel@latest
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
)
// 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 "."
}
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, "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, "")
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")
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")
}
// 1. Parse binary descriptors from positional args.
bins := parseBinaries(args)
// 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")
prefix = strings.TrimSuffix(prefix, "/")
if prefix == "" {
fatalf("run monorel from a module subdirectory, not the repo root")
}
// 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")
repoPath := normalizeGitURL(rawURL)
// 4. Collect and semver-sort tags matching "<prefix>/v*".
rawTags := run("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]
}
}
// 5. Detect dirty working tree (uncommitted / untracked files under CWD).
isDirty := run("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", "--", ".")
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.
yamlContent := goreleaserYAML(projectName, bins)
if err := os.WriteFile(".goreleaser.yaml", []byte(yamlContent), 0o644); err != nil {
fatalf("writing .goreleaser.yaml: %v", err)
}
fmt.Fprintln(os.Stderr, "wrote .goreleaser.yaml")
// 9. Emit the release script to stdout.
headSHA := mustRun("git", "rev-parse", "HEAD")
printScript(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)
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"
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
}
dp := strings.SplitN(base, ".", 3)
patch, _ := strconv.Atoi(dp[2])
patch++
preN := commitCount
if preN == 0 {
preN = 1 // dirty with no new commits still needs a label
}
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
// 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")
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]
}
}
// Same base version: pre-release < release.
if aPre == bPre {
return false
}
if aPre == "" {
return false // a is release → a > b (pre-release)
}
if bPre == "" {
return true // a is pre-release → a < b (release)
}
// Both pre-release: compare numeric suffix of "preN".
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
}
// 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 {
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.
//
// 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 <projectName>_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.
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")
// ── builds ──────────────────────────────────────────────────────────────
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")
}
// ── archives ────────────────────────────────────────────────────────────
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")
}
// ── 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")
return b.String()
}
// ── Release script generation ──────────────────────────────────────────────
// printScript writes a numbered, ready-to-review bash release script to stdout.
func printScript(
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))))
}
line("#!/usr/bin/env bash")
line("# Generated by monorel — review carefully before running!")
line("set -euo pipefail")
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.")
}
// 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
}
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)
// 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 {
line("RELEASE_NOTES=$(git --no-pager log \\")
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
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)
// Step 6 upload artifacts.
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")
// Step 7 publish.
section("Step 7: Publish release (remove draft)")
line("gh release edit %q --draft=false", currentTag)
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")
if idx := strings.Index(rawURL, "://"); idx >= 0 {
rawURL = rawURL[idx+3:]
if idx2 := strings.Index(rawURL, "@"); idx2 >= 0 {
rawURL = rawURL[idx2+1:]
}
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()
if err != nil {
fatalf("running %q %v: %v", name, args, err)
}
return strings.TrimSpace(string(out))
}
func run(name string, args ...string) string {
out, _ := exec.Command(name, args...).CombinedOutput()
return strings.TrimSpace(string(out))
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "monorel: error: "+format+"\n", args...)
os.Exit(1)
}