golib/tools/monorel/main.go
AJ ONeal 3676ef0f47
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
2026-03-01 19:13:39 -07:00

402 lines
12 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 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)
}