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).
This commit is contained in:
AJ ONeal 2026-02-28 10:34:24 -07:00
parent 3676ef0f47
commit 12c025e5e2
No known key found for this signature in database

View File

@ -1,13 +1,17 @@
// monorel: Monorepo Release Tool
//
// Run from a module subdirectory inside a git repo to:
// - Generate (or update) .goreleaser.yaml for the module
// - Print a ready-to-review bash release script to stdout
// 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
// go run github.com/therootcompany/golib/tools/monorel
// monorel .
//
// # Multiple binaries under one module
// cd io/transform/gsheet2csv
// monorel ./cmd/gsheet2csv ./cmd/gsheet2tsv ./cmd/gsheet2env
//
// Install:
//
@ -18,28 +22,58 @@ 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() {
// 1. Module prefix relative to .git root (e.g., "cmd/tcpfwd")
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")
}
// 2. Binary name = last path component of prefix
// Project name = last path component (used in checksum filename and release title).
prefixParts := strings.Split(prefix, "/")
binName := prefixParts[len(prefixParts)-1]
projectName := prefixParts[len(prefixParts)-1]
// 3. Normalised GitHub repo path (e.g., "github.com/therootcompany/golib")
// 3. Normalised GitHub repo path (e.g., "github.com/therootcompany/golib").
rawURL := mustRun("git", "remote", "get-url", "origin")
repoPath := normalizeGitURL(rawURL)
// 4. Collect tags matching "<prefix>/v*" and sort by semver
// 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") {
@ -61,10 +95,10 @@ func main() {
}
}
// 5. Detect dirty working tree (uncommitted / untracked files in this dir)
// 5. Detect dirty working tree (uncommitted / untracked files under CWD).
isDirty := run("git", "status", "--porcelain", "--", ".") != ""
// 6. Count commits since latestTag that touch this directory
// 6. Count commits since latestTag that touch the module directory.
var commitCount int
if latestTag != "" {
logOut := run("git", "log", "--oneline", latestTag+"..HEAD", "--", ".")
@ -73,38 +107,71 @@ func main() {
}
}
// 7. Derive version string, full tag, and release flags
// 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 tag that's not the one we're releasing.
// When pre-releasing, the last stable tag is latestTag (not prevStableTag).
// 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(binName)
// 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 release script to stdout
// 9. Emit the release script to stdout.
headSHA := mustRun("git", "rev-parse", "HEAD")
printScript(binName, version, currentTag, prevTag, repoPath, headSHA,
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", no changes → ("1.1.0", "cmd/tcpfwd/v1.1.0", false, false)
// 3 commits past "cmd/tcpfwd/v1.1.0" → ("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.
@ -144,6 +211,8 @@ func computeVersion(prefix, latestTag string, commitCount int, isDirty bool) (ve
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 {
@ -201,76 +270,93 @@ func preNum(s string) int {
return n
}
// goreleaserYAML returns the contents of .goreleaser.yaml for binName.
// ── goreleaser YAML generation ─────────────────────────────────────────────
// goreleaserYAML returns .goreleaser.yaml content for one or more binaries.
//
// Key design decisions:
// - Uses {{.Env.VERSION}} instead of {{.Version}} everywhere so the
// prefixed monorepo tag (cmd/tcpfwd/v1.1.0) doesn't bleed into filenames.
// - release.disable: true because we use `gh` to create the GitHub Release
// (goreleaser Pro is required to publish with a prefixed tag).
// - Checksum file is named with VERSION so it matches the archive names.
func goreleaserYAML(binName string) string {
// NOTE: "BINNAME" is our placeholder; goreleaser template markers
// ({{ ... }}) are kept verbatim this is NOT a Go text/template.
const tpl = `# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
# Generated by monorel (github.com/therootcompany/golib/tools/monorel)
// 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...) }
version: 2
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")
before:
hooks:
- go mod tidy
- go generate ./...
// ── 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")
}
builds:
- env:
- CGO_ENABLED=0
binary: BINNAME
ldflags:
- -s -w -X main.version={{.Env.VERSION}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser
goos:
- linux
- windows
- darwin
// ── 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")
}
archives:
- formats: [tar.gz]
# name_template uses VERSION env var so the prefixed monorepo tag
# (e.g. cmd/tcpfwd/v1.1.0) doesn't appear in archive filenames.
name_template: >-
BINNAME_{{ .Env.VERSION }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
format_overrides:
- goos: windows
formats: [zip]
// ── changelog ───────────────────────────────────────────────────────────
w("\nchangelog:\n sort: asc\n filters:\n exclude:\n")
w(" - \"^docs:\"\n - \"^test:\"\n")
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
// ── checksum ────────────────────────────────────────────────────────────
w("\nchecksum:\n")
wf(" name_template: \"%s_{{ .Env.VERSION }}_checksums.txt\"\n", projectName)
w(" disable: false\n")
checksum:
name_template: "BINNAME_{{ .Env.VERSION }}_checksums.txt"
disable: false
// ── 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")
# Release is disabled: goreleaser Pro is required to publish with a
# prefixed monorepo tag. We use 'gh release' instead (see release script).
release:
disable: true
`
return strings.ReplaceAll(tpl, "BINNAME", binName)
return b.String()
}
// printScript writes a bash release script to stdout.
// ── Release script generation ──────────────────────────────────────────────
// printScript writes a numbered, ready-to-review bash release script to stdout.
func printScript(
binName, version, currentTag, prevTag, repoPath, headSHA 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...) }
@ -294,7 +380,15 @@ func printScript(
// Summary comment block.
blank()
line("# %-16s %s", "Binary:", binName)
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 != "" {
@ -309,7 +403,7 @@ func printScript(
line("export VERSION=%q", version)
line("export GORELEASER_CURRENT_TAG=%q", currentTag)
// Step 2 create tag (only for clean pre-releases or first release).
// Step 2 create tag (clean pre-releases and first releases only).
if needsNewTag {
section("Step 2: Create git tag")
line("git tag %q", currentTag)
@ -318,13 +412,13 @@ func printScript(
// Step 3 build.
section("Step 3: Build with goreleaser")
line("# release.disable=true is set in .goreleaser.yaml; goreleaser only builds.")
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 log: only commits that touched files under this directory.
// 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 {
@ -334,8 +428,8 @@ func printScript(
// Step 5 create draft release.
section("Step 5: Create draft GitHub release")
tagVersion := currentTag[strings.LastIndex(currentTag, "/")+1:] // strip prefix
title := binName + " " + tagVersion
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}\" \\")
@ -348,9 +442,11 @@ func printScript(
// Step 6 upload artifacts.
section("Step 6: Upload artifacts")
line("gh release upload %q \\", currentTag)
line(" ./dist/%s_*.tar.gz \\", binName)
line(" ./dist/%s_*.zip \\", binName)
line(" \"./dist/%s_%s_checksums.txt\" \\", binName, version)
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.
@ -359,6 +455,8 @@ func printScript(
blank()
}
// ── Helpers ────────────────────────────────────────────────────────────────
// normalizeGitURL strips scheme, credentials, and .git suffix from a remote URL.
//
// https://github.com/org/repo.git → github.com/org/repo
@ -368,7 +466,6 @@ func normalizeGitURL(rawURL string) string {
rawURL = strings.TrimSuffix(rawURL, ".git")
if idx := strings.Index(rawURL, "://"); idx >= 0 {
rawURL = rawURL[idx+3:]
// Drop any "user:pass@" prefix.
if idx2 := strings.Index(rawURL, "@"); idx2 >= 0 {
rawURL = rawURL[idx2+1:]
}
@ -398,4 +495,3 @@ func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "monorel: error: "+format+"\n", args...)
os.Exit(1)
}