feat: add tools/monorel for monorepo submodule releases

Adds a standalone Go CLI tool (tools/monorel) that automates the
goreleaser + gh release workflow for modules living in a subdirectory
of a monorepo where goreleaser Pro is not available.

Run from any module subdirectory (e.g. cmd/tcpfwd):
  monorel          # writes .goreleaser.yaml + prints release script
  monorel --help   # (flag defaults)

What the tool does:
- Detects module path and binary name from git prefix
- Lists and semver-sorts tags matching <prefix>/v* (e.g. cmd/tcpfwd/v*)
- Computes version: exact tag → stable release; commits/dirty → pre-release
- Writes (or updates) .goreleaser.yaml with the binary name hard-coded,
  {{.Env.VERSION}} used for filenames instead of the prefixed tag,
  and release.disable: true (gh handles the GitHub Release)
- Prints a numbered bash script covering env vars, optional git tag,
  goreleaser build, release notes, and gh release create/upload/publish

Also updates cmd/tcpfwd/.goreleaser.yaml (first output from monorel):
- Fixes stray trailing quote in ldflags
- Sets release.disable: true (was release.footer)
- Adds generated-by header comment
This commit is contained in:
AJ ONeal 2026-02-28 02:18:13 -07:00
parent 968b375221
commit 3676ef0f47
No known key found for this signature in database
2 changed files with 404 additions and 0 deletions

3
tools/monorel/go.mod Normal file
View File

@ -0,0 +1,3 @@
module github.com/therootcompany/golib/tools/monorel
go 1.22.0

401
tools/monorel/main.go Normal file
View File

@ -0,0 +1,401 @@
// 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
//
// Usage:
//
// cd cmd/tcpfwd
// go run github.com/therootcompany/golib/tools/monorel
//
// Install:
//
// go install github.com/therootcompany/golib/tools/monorel@latest
package main
import (
"fmt"
"os"
"os/exec"
"sort"
"strconv"
"strings"
)
func main() {
// 1. Module prefix relative to .git root (e.g., "cmd/tcpfwd")
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
prefixParts := strings.Split(prefix, "/")
binName := 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 tags matching "<prefix>/v*" and sort by semver
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 in this dir)
isDirty := run("git", "status", "--porcelain", "--", ".") != ""
// 6. Count commits since latestTag that touch this 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 tag that's not the one we're releasing.
// When pre-releasing, the last stable tag is latestTag (not prevStableTag).
prevTag := prevStableTag
if isPreRelease {
prevTag = latestTag
}
// 8. Write .goreleaser.yaml
yamlContent := goreleaserYAML(binName)
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
headSHA := mustRun("git", "rev-parse", "HEAD")
printScript(binName, version, currentTag, prevTag, repoPath, headSHA,
isPreRelease, needsNewTag, isDirty)
}
// 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)
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
}
// 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
}
// goreleaserYAML returns the contents of .goreleaser.yaml for binName.
//
// 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)
version: 2
before:
hooks:
- go mod tidy
- go generate ./...
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:
- 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:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
checksum:
name_template: "BINNAME_{{ .Env.VERSION }}_checksums.txt"
disable: false
# 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)
}
// printScript writes a bash release script to stdout.
func printScript(
binName, 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()
line("# %-16s %s", "Binary:", binName)
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 (only for clean pre-releases or first release).
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 is set 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.
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 prefix
title := binName + " " + 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)
line(" ./dist/%s_*.tar.gz \\", binName)
line(" ./dist/%s_*.zip \\", binName)
line(" \"./dist/%s_%s_checksums.txt\" \\", binName, version)
line(" --clobber")
// Step 7 publish.
section("Step 7: Publish release (remove draft)")
line("gh release edit %q --draft=false", currentTag)
blank()
}
// 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:]
// Drop any "user:pass@" prefix.
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)
}