golib/tools/monorel/main.go
AJ ONeal ae46430b7b
fix(monorel): bump tags the latest commit touching the module, not HEAD
In a monorepo the module's most recent commit is often behind HEAD
(other modules may have been committed on top).

  git log --format=%H -1 -- .

run from the module root returns the SHA of the last commit that
touched that directory; we pass it explicitly to `git tag <tag> <sha>`
instead of letting git default to HEAD.
2026-03-01 19:13:49 -07:00

883 lines
29 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, and performs the requested
// subcommand.
//
// Subcommands:
//
// monorel release <binary-path>...
// Generate .goreleaser.yaml and print a ready-to-review bash release script.
//
// monorel bump [-r major|minor|patch] <binary-path>...
// Create a new semver git tag at HEAD for each module (default: patch).
//
// monorel init <binary-path>...
// Write .goreleaser.yaml, commit it, and run bump patch for each module
// (processed in the order their paths appear on the command line).
//
// Install:
//
// go install github.com/therootcompany/golib/tools/monorel@latest
package main
import (
"flag"
"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() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
switch os.Args[1] {
case "release":
runRelease(os.Args[2:])
case "bump":
runBump(os.Args[2:])
case "init":
runInit(os.Args[2:])
case "help", "--help", "-h":
usage()
default:
fmt.Fprintf(os.Stderr, "monorel: unknown subcommand %q\n", os.Args[1])
fmt.Fprintln(os.Stderr, "Run 'monorel help' for usage.")
os.Exit(2)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "monorel: Monorepo Release Tool")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Usage:")
fmt.Fprintln(os.Stderr, " monorel <subcommand> [options] <binary-path>...")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Subcommands:")
fmt.Fprintln(os.Stderr, " release Write .goreleaser.yaml and print a bash release script")
fmt.Fprintln(os.Stderr, " bump Create a new semver tag at HEAD (default: patch)")
fmt.Fprintln(os.Stderr, " init Write .goreleaser.yaml, commit it, and bump patch")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Each <binary-path> points to a Go main package directory. monorel")
fmt.Fprintln(os.Stderr, "walks up from each path to find the module root (go.mod), stopping")
fmt.Fprintln(os.Stderr, "at the repository boundary (.git directory).")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Run 'monorel <subcommand> --help' for subcommand-specific usage.")
}
// ── Subcommand: release ────────────────────────────────────────────────────
func runRelease(args []string) {
fs := flag.NewFlagSet("monorel release", flag.ExitOnError)
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "usage: monorel release <binary-path>...")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Writes .goreleaser.yaml next to each module's go.mod and prints a")
fmt.Fprintln(os.Stderr, "ready-to-review bash release script to stdout.")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Examples:")
fmt.Fprintln(os.Stderr, " monorel release . # single binary at module root")
fmt.Fprintln(os.Stderr, " monorel release ./cmd/foo ./cmd/bar # multiple binaries, same module")
fmt.Fprintln(os.Stderr, " monorel release auth/csvauth/cmd/csvauth # from repo root")
fmt.Fprintln(os.Stderr, "")
fs.PrintDefaults()
}
_ = fs.Parse(args)
binPaths := fs.Args()
if len(binPaths) == 0 {
fs.Usage()
os.Exit(2)
}
groups, err := groupByModule(binPaths)
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)
}
}
// ── Subcommand: bump ───────────────────────────────────────────────────────
func runBump(args []string) {
fs := flag.NewFlagSet("monorel bump", flag.ExitOnError)
var component string
fs.StringVar(&component, "r", "patch", "version component to bump: major, minor, or patch")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "usage: monorel bump [-r major|minor|patch] <binary-path>...")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Creates a new semver git tag at HEAD for the module of each binary path.")
fmt.Fprintln(os.Stderr, "The tag is created locally; push it with 'git push --tags'.")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Examples:")
fmt.Fprintln(os.Stderr, " monorel bump ./cmd/csvauth # bump patch (default)")
fmt.Fprintln(os.Stderr, " monorel bump -r minor ./cmd/csvauth # bump minor")
fmt.Fprintln(os.Stderr, " monorel bump -r major ./cmd/csvauth # bump major")
fmt.Fprintln(os.Stderr, "")
fs.PrintDefaults()
}
_ = fs.Parse(args)
switch component {
case "major", "minor", "patch":
// valid
default:
fmt.Fprintf(os.Stderr, "monorel bump: -r must be major, minor, or patch (got %q)\n", component)
os.Exit(2)
}
binPaths := fs.Args()
if len(binPaths) == 0 {
fs.Usage()
os.Exit(2)
}
groups, err := groupByModule(binPaths)
if err != nil {
fatalf("%v", err)
}
for _, group := range groups {
newTag := bumpModuleTag(group, component)
fmt.Fprintf(os.Stderr, "created tag: %s\n", newTag)
}
}
// ── Subcommand: init ───────────────────────────────────────────────────────
func runInit(args []string) {
fs := flag.NewFlagSet("monorel init", flag.ExitOnError)
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "usage: monorel init <binary-path>...")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "For each module (in command-line order):")
fmt.Fprintln(os.Stderr, " 1. Writes .goreleaser.yaml next to go.mod")
fmt.Fprintln(os.Stderr, " 2. Commits it (skipped if file is unchanged)")
fmt.Fprintln(os.Stderr, " 3. Creates an initial version tag (equivalent to 'bump patch')")
fmt.Fprintln(os.Stderr, "")
fs.PrintDefaults()
}
_ = fs.Parse(args)
binPaths := fs.Args()
if len(binPaths) == 0 {
fs.Usage()
os.Exit(2)
}
groups, err := groupByModule(binPaths)
if err != nil {
fatalf("%v", err)
}
for _, group := range groups {
initModuleGroup(group)
}
}
// initModuleGroup writes .goreleaser.yaml, commits it (if changed), and
// creates an initial version tag (bump patch) for one module group.
func initModuleGroup(group *moduleGroup) {
modRoot := group.root
bins := group.bins
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]
// 1. Write .goreleaser.yaml.
yamlContent := goreleaserYAML(projectName, bins)
yamlPath := filepath.Join(modRoot, ".goreleaser.yaml")
if err := os.WriteFile(yamlPath, []byte(yamlContent), 0o644); err != nil {
fatalf("writing %s: %v", yamlPath, err)
}
fmt.Fprintf(os.Stderr, "wrote %s\n", yamlPath)
// 2. Stage and commit if the file changed.
mustRunIn(modRoot, "git", "add", ".goreleaser.yaml")
if status := runIn(modRoot, "git", "status", "--porcelain", "--", ".goreleaser.yaml"); status != "" {
commitMsg := "chore(release): add .goreleaser.yaml for " + projectName
mustRunIn(modRoot, "git", "commit", "-m", commitMsg)
fmt.Fprintf(os.Stderr, "committed: %s\n", commitMsg)
} else {
fmt.Fprintf(os.Stderr, "note: .goreleaser.yaml unchanged, skipping commit\n")
}
// 3. Bump patch.
newTag := bumpModuleTag(group, "patch")
fmt.Fprintf(os.Stderr, "created tag: %s\n", newTag)
}
// ── Bump helpers ───────────────────────────────────────────────────────────
// bumpModuleTag finds the latest stable tag for the module, computes the next
// version by bumping the given component (major, minor, or patch), creates the
// git tag at HEAD, and returns the new tag name.
func bumpModuleTag(group *moduleGroup, component string) string {
modRoot := group.root
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)
}
// Collect stable tags only (no pre-release suffix) for this module.
rawTags := runIn(modRoot, "git", "tag", "--list", prefix+"/v*")
var stableTags []string
for _, t := range strings.Split(rawTags, "\n") {
t = strings.TrimSpace(t)
if t == "" {
continue
}
ver := strings.TrimPrefix(t, prefix+"/")
if !strings.Contains(ver, "-") { // pre-releases have a "-" in the version
stableTags = append(stableTags, t)
}
}
sort.Slice(stableTags, func(i, j int) bool {
vi := strings.TrimPrefix(stableTags[i], prefix+"/")
vj := strings.TrimPrefix(stableTags[j], prefix+"/")
return semverLess(vi, vj)
})
var latestStable string
if n := len(stableTags); n > 0 {
latestStable = stableTags[n-1]
}
newTag := computeBumpTag(prefix, latestStable, component)
// Tag the most recent commit that touched this module's directory, which
// may be behind HEAD if other modules have been updated more recently.
commitSHA := mustRunIn(modRoot, "git", "log", "--format=%H", "-1", "--", ".")
if commitSHA == "" {
fatalf("no commits found in %s", modRoot)
}
mustRunIn(modRoot, "git", "tag", newTag, commitSHA)
return newTag
}
// computeBumpTag returns the new full tag string for the given bump component,
// starting from latestStableTag (empty string = no prior stable tags).
func computeBumpTag(prefix, latestStableTag, component string) string {
if latestStableTag == "" {
switch component {
case "major":
return prefix + "/v1.0.0"
default: // minor, patch
return prefix + "/v0.1.0"
}
}
semver := strings.TrimPrefix(latestStableTag, prefix+"/v")
dp := strings.SplitN(semver, ".", 3)
for len(dp) < 3 {
dp = append(dp, "0")
}
major, _ := strconv.Atoi(dp[0])
minor, _ := strconv.Atoi(dp[1])
patch, _ := strconv.Atoi(dp[2])
switch component {
case "major":
major++
minor, patch = 0, 0
case "minor":
minor++
patch = 0
default: // patch
patch++
}
return fmt.Sprintf("%s/v%d.%d.%d", prefix, major, minor, patch)
}
// ── 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)
}