mirror of
https://github.com/therootcompany/golib.git
synced 2026-03-02 23:57:59 +00:00
feat(monorel): auto-discover module root; submodule-aware .git detection
Major refactor: monorel no longer requires being run from the module root. It now accepts paths to binary packages from any ancestor directory and walks up from each path to find go.mod (groupByModule / findModuleRoot). Binaries sharing a module root are grouped together. When the script must cd into a module directory (multi-module run or different CWD) the per-module output is wrapped in a bash subshell to prevent directory leakage. The .git stop-marker now only triggers on a .git DIRECTORY, not a .git FILE. A .git file means the path is inside a submodule whose real repository root is further up the tree, so the search continues upward.
This commit is contained in:
parent
5716f0f757
commit
862eeebd6d
@ -1,17 +1,20 @@
|
|||||||
// monorel: Monorepo Release Tool
|
// monorel: Monorepo Release Tool
|
||||||
//
|
//
|
||||||
// Run from a module directory and pass the paths to each binary's main
|
// Pass any number of paths to Go main packages. monorel walks up from each
|
||||||
// package. Supports both single-binary and multi-binary modules.
|
// path to find its go.mod (stopping at .git so it never crosses the repo
|
||||||
|
// boundary), groups binaries by their module root, writes a .goreleaser.yaml
|
||||||
|
// for each module, and prints a ready-to-review bash release script.
|
||||||
//
|
//
|
||||||
// Usage:
|
// Usage:
|
||||||
//
|
//
|
||||||
// # Single binary (path to the main package, or "." for module root)
|
// # From within a module directory:
|
||||||
// cd cmd/tcpfwd
|
// monorel . # single binary at root
|
||||||
// monorel .
|
// monorel ./cmd/foo ./cmd/bar ./cmd/baz # multiple binaries
|
||||||
//
|
//
|
||||||
// # Multiple binaries under one module
|
// # From any ancestor directory (e.g. the repo root):
|
||||||
// cd io/transform/gsheet2csv
|
// monorel io/transform/gsheet2csv/cmd/gsheet2csv \
|
||||||
// monorel ./cmd/gsheet2csv ./cmd/gsheet2tsv ./cmd/gsheet2env
|
// io/transform/gsheet2csv/cmd/gsheet2tsv \
|
||||||
|
// auth/csvauth/cmd/csvauth
|
||||||
//
|
//
|
||||||
// Install:
|
// Install:
|
||||||
//
|
//
|
||||||
@ -28,82 +31,179 @@ import (
|
|||||||
"strings"
|
"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.
|
// binary describes one Go main package to build and release.
|
||||||
type binary struct {
|
type binary struct {
|
||||||
name string // last path component, e.g. "gsheet2csv"
|
name string // last path component, e.g. "gsheet2csv"
|
||||||
mainPath string // path relative to module dir, e.g. "./cmd/gsheet2csv" or "."
|
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() {
|
func main() {
|
||||||
args := os.Args[1:]
|
args := os.Args[1:]
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
fmt.Fprintln(os.Stderr, "usage: monorel <binary-path> [<binary-path>...]")
|
fmt.Fprintln(os.Stderr, "usage: monorel <binary-path> [<binary-path>...]")
|
||||||
fmt.Fprintln(os.Stderr, "")
|
fmt.Fprintln(os.Stderr, "")
|
||||||
fmt.Fprintln(os.Stderr, "Run from the module directory (where go.mod lives).")
|
fmt.Fprintln(os.Stderr, "Each path points to a Go main package (directory).")
|
||||||
fmt.Fprintln(os.Stderr, "Use '.' when the module root is itself the main package.")
|
fmt.Fprintln(os.Stderr, "The module root (go.mod) is found by walking up from each path,")
|
||||||
|
fmt.Fprintln(os.Stderr, "stopping at .git so it never crosses the repository boundary.")
|
||||||
fmt.Fprintln(os.Stderr, "")
|
fmt.Fprintln(os.Stderr, "")
|
||||||
fmt.Fprintln(os.Stderr, "Examples:")
|
fmt.Fprintln(os.Stderr, "Examples:")
|
||||||
fmt.Fprintln(os.Stderr, " monorel . # single binary at root")
|
fmt.Fprintln(os.Stderr, " monorel . # single binary at module root")
|
||||||
fmt.Fprintln(os.Stderr, " monorel ./cmd/foo ./cmd/bar ./cmd/baz # multiple binaries")
|
fmt.Fprintln(os.Stderr, " monorel ./cmd/foo ./cmd/bar # multiple binaries, same module")
|
||||||
|
fmt.Fprintln(os.Stderr, " monorel io/transform/gsheet2csv/cmd/foo \\ # from the repo root")
|
||||||
|
fmt.Fprintln(os.Stderr, " auth/csvauth/cmd/bar")
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must run from the module directory so goreleaser can find go.mod and
|
groups, err := groupByModule(args)
|
||||||
// so that .goreleaser.yaml is written next to it.
|
if err != nil {
|
||||||
if _, err := os.Stat("go.mod"); err != nil {
|
fatalf("%v", err)
|
||||||
fatalf("no go.mod in current directory; run monorel from the module root")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Parse binary descriptors from positional args.
|
cwd, _ := os.Getwd()
|
||||||
bins := parseBinaries(args)
|
multiModule := len(groups) > 1
|
||||||
|
|
||||||
// Guard: binary paths must be strictly inside the module directory, and no
|
// Emit the bash header exactly once.
|
||||||
// directory along the path (between the module root and the binary itself,
|
fmt.Println("#!/usr/bin/env bash")
|
||||||
// inclusive) may contain its own go.mod.
|
fmt.Println("# Generated by monorel — review carefully before running!")
|
||||||
//
|
fmt.Println("set -euo pipefail")
|
||||||
// Examples that must be rejected:
|
|
||||||
// ../other (escapes the module root)
|
for _, group := range groups {
|
||||||
// ./cmd/go.mod (intermediate dir is its own module)
|
relPath, _ := filepath.Rel(cwd, group.root)
|
||||||
// ./cmd/foo/go.mod (binary dir is its own module)
|
relPath = filepath.ToSlash(relPath)
|
||||||
for _, bin := range bins {
|
// Wrap in a subshell when the script has to cd somewhere, so multiple
|
||||||
if bin.mainPath == "." {
|
// module sections don't interfere with each other.
|
||||||
continue // the module root itself — already checked above
|
wrap := multiModule || relPath != "."
|
||||||
|
processModule(group, relPath, wrap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(bin.mainPath, "../") {
|
for _, stop := range stopMarkers {
|
||||||
fatalf("%s is outside the module directory", bin.mainPath)
|
info, err := os.Stat(filepath.Join(dir, stop))
|
||||||
}
|
// A .git FILE means submodule — keep looking up the chain.
|
||||||
// Walk every directory segment from the first child of the module root
|
// Only a .git DIRECTORY marks the true repository root.
|
||||||
// down to the binary directory.
|
if err == nil && info.IsDir() {
|
||||||
rel := strings.TrimPrefix(bin.mainPath, "./")
|
return "", fmt.Errorf(
|
||||||
parts := strings.Split(rel, "/")
|
"no go.mod found between %s and the repository root (%s)",
|
||||||
for i := range parts {
|
absDir, dir)
|
||||||
dir := "./" + strings.Join(parts[:i+1], "/")
|
|
||||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
|
||||||
fatalf("%s has its own go.mod — it is a separate module.\n"+
|
|
||||||
" Run monorel from that directory instead:\n"+
|
|
||||||
" cd %s && monorel .", dir, dir)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
return "", fmt.Errorf("no go.mod found above %s", absDir)
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Module prefix relative to the .git root (e.g., "io/transform/gsheet2csv").
|
groups := make([]*moduleGroup, len(order))
|
||||||
// This is also the tag prefix, e.g. "io/transform/gsheet2csv/v1.2.3".
|
for i, root := range order {
|
||||||
prefix := mustRun("git", "rev-parse", "--show-prefix")
|
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 (used for the cd step in the script). wrap=true wraps the
|
||||||
|
// output in a bash subshell.
|
||||||
|
func processModule(group *moduleGroup, relPath string, wrap bool) {
|
||||||
|
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, "/")
|
prefix = strings.TrimSuffix(prefix, "/")
|
||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
fatalf("run monorel from a module subdirectory, not the repo root")
|
fatalf("%s appears to be the repo root; the module must be in a subdirectory", modRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Project name = last path component (used in checksum filename and release title).
|
|
||||||
prefixParts := strings.Split(prefix, "/")
|
prefixParts := strings.Split(prefix, "/")
|
||||||
projectName := prefixParts[len(prefixParts)-1]
|
projectName := prefixParts[len(prefixParts)-1]
|
||||||
|
|
||||||
// 3. Normalised GitHub repo path (e.g., "github.com/therootcompany/golib").
|
rawURL := mustRunIn(modRoot, "git", "remote", "get-url", "origin")
|
||||||
rawURL := mustRun("git", "remote", "get-url", "origin")
|
|
||||||
repoPath := normalizeGitURL(rawURL)
|
repoPath := normalizeGitURL(rawURL)
|
||||||
|
|
||||||
// 4. Collect and semver-sort tags matching "<prefix>/v*".
|
// Collect and semver-sort tags matching "<prefix>/v*".
|
||||||
rawTags := run("git", "tag", "--list", prefix+"/v*")
|
rawTags := runIn(modRoot, "git", "tag", "--list", prefix+"/v*")
|
||||||
var tags []string
|
var tags []string
|
||||||
for _, t := range strings.Split(rawTags, "\n") {
|
for _, t := range strings.Split(rawTags, "\n") {
|
||||||
if t = strings.TrimSpace(t); t != "" {
|
if t = strings.TrimSpace(t); t != "" {
|
||||||
@ -124,101 +224,63 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Detect dirty working tree (uncommitted / untracked files under CWD).
|
isDirty := runIn(modRoot, "git", "status", "--porcelain", "--", ".") != ""
|
||||||
isDirty := run("git", "status", "--porcelain", "--", ".") != ""
|
|
||||||
|
|
||||||
// 6. Count commits since latestTag that touch the module directory.
|
|
||||||
var commitCount int
|
var commitCount int
|
||||||
if latestTag != "" {
|
if latestTag != "" {
|
||||||
logOut := run("git", "log", "--oneline", latestTag+"..HEAD", "--", ".")
|
logOut := runIn(modRoot, "git", "log", "--oneline", latestTag+"..HEAD", "--", ".")
|
||||||
if logOut != "" {
|
if logOut != "" {
|
||||||
commitCount = len(strings.Split(logOut, "\n"))
|
commitCount = len(strings.Split(logOut, "\n"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Derive version string, full tag, and release flags.
|
|
||||||
version, currentTag, isPreRelease, needsNewTag := computeVersion(
|
version, currentTag, isPreRelease, needsNewTag := computeVersion(
|
||||||
prefix, latestTag, commitCount, isDirty,
|
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
|
prevTag := prevStableTag
|
||||||
if isPreRelease {
|
if isPreRelease {
|
||||||
prevTag = latestTag
|
prevTag = latestTag
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Write .goreleaser.yaml.
|
// Write .goreleaser.yaml next to go.mod.
|
||||||
yamlContent := goreleaserYAML(projectName, bins)
|
yamlContent := goreleaserYAML(projectName, bins)
|
||||||
if err := os.WriteFile(".goreleaser.yaml", []byte(yamlContent), 0o644); err != nil {
|
yamlPath := filepath.Join(modRoot, ".goreleaser.yaml")
|
||||||
fatalf("writing .goreleaser.yaml: %v", err)
|
if err := os.WriteFile(yamlPath, []byte(yamlContent), 0o644); err != nil {
|
||||||
|
fatalf("writing %s: %v", yamlPath, err)
|
||||||
}
|
}
|
||||||
fmt.Fprintln(os.Stderr, "wrote .goreleaser.yaml")
|
fmt.Fprintf(os.Stderr, "wrote %s\n", yamlPath)
|
||||||
|
|
||||||
// 9. Emit the release script to stdout.
|
headSHA := mustRunIn(modRoot, "git", "rev-parse", "HEAD")
|
||||||
headSHA := mustRun("git", "rev-parse", "HEAD")
|
printModuleScript(relPath, wrap, projectName, bins,
|
||||||
printScript(projectName, bins, version, currentTag, prevTag, repoPath, headSHA,
|
version, currentTag, prevTag, repoPath, headSHA,
|
||||||
isPreRelease, needsNewTag, isDirty)
|
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 ────────────────────────────────────────────────────
|
// ── Version computation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
// computeVersion returns (version, fullTag, isPreRelease, needsNewTag).
|
// computeVersion returns (version, fullTag, isPreRelease, needsNewTag).
|
||||||
//
|
//
|
||||||
// Examples:
|
// Examples:
|
||||||
//
|
//
|
||||||
// At "cmd/tcpfwd/v1.1.0", clean → ("1.1.0", "cmd/tcpfwd/v1.1.0", false, 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)
|
// 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)
|
// 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) {
|
func computeVersion(prefix, latestTag string, commitCount int, isDirty bool) (version, currentTag string, isPreRelease, needsNewTag bool) {
|
||||||
if latestTag == "" {
|
if latestTag == "" {
|
||||||
// Very first release – default to v0.1.0.
|
|
||||||
return "0.1.0", prefix + "/v0.1.0", false, true
|
return "0.1.0", prefix + "/v0.1.0", false, true
|
||||||
}
|
}
|
||||||
|
|
||||||
tagSemver := strings.TrimPrefix(latestTag, prefix+"/") // e.g., "v1.1.0"
|
tagSemver := strings.TrimPrefix(latestTag, prefix+"/")
|
||||||
|
|
||||||
if commitCount == 0 && !isDirty {
|
if commitCount == 0 && !isDirty {
|
||||||
// HEAD is exactly at the tag.
|
|
||||||
version = strings.TrimPrefix(tagSemver, "v")
|
version = strings.TrimPrefix(tagSemver, "v")
|
||||||
return version, latestTag, false, false
|
return version, latestTag, false, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-release: bump patch of the base release version.
|
|
||||||
base := strings.TrimPrefix(tagSemver, "v")
|
base := strings.TrimPrefix(tagSemver, "v")
|
||||||
if idx := strings.Index(base, "-"); idx >= 0 {
|
if idx := strings.Index(base, "-"); idx >= 0 {
|
||||||
base = base[:idx] // drop any existing pre-release label
|
base = base[:idx]
|
||||||
}
|
}
|
||||||
dp := strings.SplitN(base, ".", 3)
|
dp := strings.SplitN(base, ".", 3)
|
||||||
patch, _ := strconv.Atoi(dp[2])
|
patch, _ := strconv.Atoi(dp[2])
|
||||||
@ -226,7 +288,7 @@ func computeVersion(prefix, latestTag string, commitCount int, isDirty bool) (ve
|
|||||||
|
|
||||||
preN := commitCount
|
preN := commitCount
|
||||||
if preN == 0 {
|
if preN == 0 {
|
||||||
preN = 1 // dirty with no new commits still needs a label
|
preN = 1
|
||||||
}
|
}
|
||||||
preLabel := fmt.Sprintf("pre%d", preN)
|
preLabel := fmt.Sprintf("pre%d", preN)
|
||||||
if isDirty {
|
if isDirty {
|
||||||
@ -235,15 +297,12 @@ func computeVersion(prefix, latestTag string, commitCount int, isDirty bool) (ve
|
|||||||
|
|
||||||
version = fmt.Sprintf("%s.%s.%d-%s", dp[0], dp[1], patch, preLabel)
|
version = fmt.Sprintf("%s.%s.%d-%s", dp[0], dp[1], patch, preLabel)
|
||||||
currentTag = prefix + "/v" + version
|
currentTag = prefix + "/v" + version
|
||||||
// Only create a new tag for clean (non-dirty) pre-releases.
|
|
||||||
needsNewTag = !isDirty
|
needsNewTag = !isDirty
|
||||||
return version, currentTag, true, needsNewTag
|
return version, currentTag, true, needsNewTag
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Semver helpers ─────────────────────────────────────────────────────────
|
// ── 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 {
|
func semverLess(a, b string) bool {
|
||||||
a = strings.TrimPrefix(a, "v")
|
a = strings.TrimPrefix(a, "v")
|
||||||
b = strings.TrimPrefix(b, "v")
|
b = strings.TrimPrefix(b, "v")
|
||||||
@ -262,18 +321,15 @@ func semverLess(a, b string) bool {
|
|||||||
return aP[i] < bP[i]
|
return aP[i] < bP[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same base version: pre-release < release.
|
|
||||||
if aPre == bPre {
|
if aPre == bPre {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if aPre == "" {
|
if aPre == "" {
|
||||||
return false // a is release → a > b (pre-release)
|
return false
|
||||||
}
|
}
|
||||||
if bPre == "" {
|
if bPre == "" {
|
||||||
return true // a is pre-release → a < b (release)
|
return true
|
||||||
}
|
}
|
||||||
// Both pre-release: compare numeric suffix of "preN".
|
|
||||||
return preNum(aPre) < preNum(bPre)
|
return preNum(aPre) < preNum(bPre)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -286,7 +342,6 @@ func semverInts(v string) [3]int {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// preNum extracts the numeric value from a pre-release label like "pre3" or "pre3.dirty".
|
|
||||||
func preNum(s string) int {
|
func preNum(s string) int {
|
||||||
s = strings.TrimPrefix(s, "pre")
|
s = strings.TrimPrefix(s, "pre")
|
||||||
if idx := strings.IndexAny(s, ".+"); idx >= 0 {
|
if idx := strings.IndexAny(s, ".+"); idx >= 0 {
|
||||||
@ -303,16 +358,12 @@ func preNum(s string) int {
|
|||||||
|
|
||||||
// goreleaserYAML returns .goreleaser.yaml content for one or more binaries.
|
// goreleaserYAML returns .goreleaser.yaml content for one or more binaries.
|
||||||
//
|
//
|
||||||
// Design decisions:
|
// Key decisions:
|
||||||
// - Uses {{.Env.VERSION}} instead of {{.Version}} everywhere so a prefixed
|
// - {{.Env.VERSION}} is used everywhere so the prefixed monorepo tag never
|
||||||
// monorepo tag (e.g. io/transform/gsheet2csv/v1.2.3) never bleeds into
|
// appears in artifact filenames.
|
||||||
// artifact filenames.
|
// - Each binary gets its own build (id) and archive (ids) for separate tarballs.
|
||||||
// - Each binary gets its own build (with id) and its own archive (with ids)
|
// - release.disable: true — we use `gh release` instead (goreleaser Pro
|
||||||
// so cross-platform tarballs are separate per tool.
|
// would be needed to publish via a prefixed tag).
|
||||||
// - 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 {
|
func goreleaserYAML(projectName string, bins []binary) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
w := func(s string) { b.WriteString(s) }
|
w := func(s string) { b.WriteString(s) }
|
||||||
@ -324,7 +375,6 @@ func goreleaserYAML(projectName string, bins []binary) string {
|
|||||||
w("\nversion: 2\n")
|
w("\nversion: 2\n")
|
||||||
w("\nbefore:\n hooks:\n - go mod tidy\n - go generate ./...\n")
|
w("\nbefore:\n hooks:\n - go mod tidy\n - go generate ./...\n")
|
||||||
|
|
||||||
// ── builds ──────────────────────────────────────────────────────────────
|
|
||||||
w("\nbuilds:\n")
|
w("\nbuilds:\n")
|
||||||
for _, bin := range bins {
|
for _, bin := range bins {
|
||||||
wf(" - id: %s\n", bin.name)
|
wf(" - id: %s\n", bin.name)
|
||||||
@ -342,7 +392,6 @@ func goreleaserYAML(projectName string, bins []binary) string {
|
|||||||
w(" goos:\n - linux\n - windows\n - darwin\n")
|
w(" goos:\n - linux\n - windows\n - darwin\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── archives ────────────────────────────────────────────────────────────
|
|
||||||
w("\narchives:\n")
|
w("\narchives:\n")
|
||||||
for _, bin := range bins {
|
for _, bin := range bins {
|
||||||
wf(" - id: %s\n", bin.name)
|
wf(" - id: %s\n", bin.name)
|
||||||
@ -362,16 +411,13 @@ func goreleaserYAML(projectName string, bins []binary) string {
|
|||||||
w(" formats: [zip]\n")
|
w(" formats: [zip]\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── changelog ───────────────────────────────────────────────────────────
|
|
||||||
w("\nchangelog:\n sort: asc\n filters:\n exclude:\n")
|
w("\nchangelog:\n sort: asc\n filters:\n exclude:\n")
|
||||||
w(" - \"^docs:\"\n - \"^test:\"\n")
|
w(" - \"^docs:\"\n - \"^test:\"\n")
|
||||||
|
|
||||||
// ── checksum ────────────────────────────────────────────────────────────
|
|
||||||
w("\nchecksum:\n")
|
w("\nchecksum:\n")
|
||||||
wf(" name_template: \"%s_{{ .Env.VERSION }}_checksums.txt\"\n", projectName)
|
wf(" name_template: \"%s_{{ .Env.VERSION }}_checksums.txt\"\n", projectName)
|
||||||
w(" disable: false\n")
|
w(" disable: false\n")
|
||||||
|
|
||||||
// ── release ─────────────────────────────────────────────────────────────
|
|
||||||
w("\n# Release is disabled: goreleaser Pro is required to publish with a\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("# prefixed monorepo tag. We use 'gh release' instead (see release script).\n")
|
||||||
w("release:\n disable: true\n")
|
w("release:\n disable: true\n")
|
||||||
@ -381,10 +427,12 @@ func goreleaserYAML(projectName string, bins []binary) string {
|
|||||||
|
|
||||||
// ── Release script generation ──────────────────────────────────────────────
|
// ── Release script generation ──────────────────────────────────────────────
|
||||||
|
|
||||||
// printScript writes a numbered, ready-to-review bash release script to stdout.
|
// printModuleScript emits one module's release steps to stdout.
|
||||||
func printScript(
|
// If wrap=true the output is enclosed in a bash subshell ( ... ) with a cd
|
||||||
projectName string,
|
// at the top, so multiple modules in one script don't interfere.
|
||||||
bins []binary,
|
func printModuleScript(
|
||||||
|
relPath string, wrap bool,
|
||||||
|
projectName string, bins []binary,
|
||||||
version, currentTag, prevTag, repoPath, headSHA string,
|
version, currentTag, prevTag, repoPath, headSHA string,
|
||||||
isPreRelease, needsNewTag, isDirty bool,
|
isPreRelease, needsNewTag, isDirty bool,
|
||||||
) {
|
) {
|
||||||
@ -396,9 +444,15 @@ func printScript(
|
|||||||
fmt.Println(strings.Repeat("─", max(0, 52-len(title))))
|
fmt.Println(strings.Repeat("─", max(0, 52-len(title))))
|
||||||
}
|
}
|
||||||
|
|
||||||
line("#!/usr/bin/env bash")
|
if wrap {
|
||||||
line("# Generated by monorel — review carefully before running!")
|
blank()
|
||||||
line("set -euo pipefail")
|
rule := strings.Repeat("═", 54)
|
||||||
|
fmt.Printf("# %s\n", rule)
|
||||||
|
fmt.Printf("# Module: %s\n", relPath)
|
||||||
|
fmt.Printf("# %s\n", rule)
|
||||||
|
fmt.Println("(")
|
||||||
|
fmt.Printf("cd %q\n", relPath)
|
||||||
|
}
|
||||||
|
|
||||||
if isDirty {
|
if isDirty {
|
||||||
blank()
|
blank()
|
||||||
@ -407,14 +461,13 @@ func printScript(
|
|||||||
line("# A .dirty suffix has been appended to the version below.")
|
line("# A .dirty suffix has been appended to the version below.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary comment block.
|
|
||||||
blank()
|
blank()
|
||||||
if len(bins) == 1 {
|
if len(bins) == 1 {
|
||||||
line("# %-16s %s", "Binary:", bins[0].name)
|
line("# %-16s %s", "Binary:", bins[0].name)
|
||||||
} else {
|
} else {
|
||||||
names := make([]string, len(bins))
|
names := make([]string, len(bins))
|
||||||
for i, b := range bins {
|
for i, bin := range bins {
|
||||||
names[i] = b.name
|
names[i] = bin.name
|
||||||
}
|
}
|
||||||
line("# %-16s %s", "Binaries:", strings.Join(names, ", "))
|
line("# %-16s %s", "Binaries:", strings.Join(names, ", "))
|
||||||
}
|
}
|
||||||
@ -427,27 +480,22 @@ func printScript(
|
|||||||
}
|
}
|
||||||
line("# %-16s %s", "Repo:", repoPath)
|
line("# %-16s %s", "Repo:", repoPath)
|
||||||
|
|
||||||
// Step 1 – env vars.
|
|
||||||
section("Step 1: Environment variables")
|
section("Step 1: Environment variables")
|
||||||
line("export VERSION=%q", version)
|
line("export VERSION=%q", version)
|
||||||
line("export GORELEASER_CURRENT_TAG=%q", currentTag)
|
line("export GORELEASER_CURRENT_TAG=%q", currentTag)
|
||||||
|
|
||||||
// Step 2 – create tag (clean pre-releases and first releases only).
|
|
||||||
if needsNewTag {
|
if needsNewTag {
|
||||||
section("Step 2: Create git tag")
|
section("Step 2: Create git tag")
|
||||||
line("git tag %q", currentTag)
|
line("git tag %q", currentTag)
|
||||||
line("# To undo: git tag -d %q", currentTag)
|
line("# To undo: git tag -d %q", currentTag)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3 – build.
|
|
||||||
section("Step 3: Build with goreleaser")
|
section("Step 3: Build with goreleaser")
|
||||||
line("# release.disable=true in .goreleaser.yaml; goreleaser only builds.")
|
line("# release.disable=true in .goreleaser.yaml; goreleaser only builds.")
|
||||||
line("goreleaser release --clean --skip=validate,announce")
|
line("goreleaser release --clean --skip=validate,announce")
|
||||||
|
|
||||||
// Step 4 – release notes.
|
|
||||||
section("Step 4: Generate release notes")
|
section("Step 4: Generate release notes")
|
||||||
if prevTag != "" {
|
if prevTag != "" {
|
||||||
// Path-limited: only commits touching files under the module directory.
|
|
||||||
line("RELEASE_NOTES=$(git --no-pager log %q..HEAD \\", prevTag)
|
line("RELEASE_NOTES=$(git --no-pager log %q..HEAD \\", prevTag)
|
||||||
line(" --pretty=format:'- %%h %%s' -- ./)")
|
line(" --pretty=format:'- %%h %%s' -- ./)")
|
||||||
} else {
|
} else {
|
||||||
@ -455,9 +503,8 @@ func printScript(
|
|||||||
line(" --pretty=format:'- %%h %%s' -- ./)")
|
line(" --pretty=format:'- %%h %%s' -- ./)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5 – create draft release.
|
|
||||||
section("Step 5: Create draft GitHub release")
|
section("Step 5: Create draft GitHub release")
|
||||||
tagVersion := currentTag[strings.LastIndex(currentTag, "/")+1:] // strip module prefix
|
tagVersion := currentTag[strings.LastIndex(currentTag, "/")+1:]
|
||||||
title := projectName + " " + tagVersion
|
title := projectName + " " + tagVersion
|
||||||
line("gh release create %q \\", currentTag)
|
line("gh release create %q \\", currentTag)
|
||||||
line(" --title %q \\", title)
|
line(" --title %q \\", title)
|
||||||
@ -468,7 +515,6 @@ func printScript(
|
|||||||
line(" --draft \\")
|
line(" --draft \\")
|
||||||
line(" --target %q", headSHA)
|
line(" --target %q", headSHA)
|
||||||
|
|
||||||
// Step 6 – upload artifacts.
|
|
||||||
section("Step 6: Upload artifacts")
|
section("Step 6: Upload artifacts")
|
||||||
line("gh release upload %q \\", currentTag)
|
line("gh release upload %q \\", currentTag)
|
||||||
for _, bin := range bins {
|
for _, bin := range bins {
|
||||||
@ -478,18 +524,18 @@ func printScript(
|
|||||||
line(" \"./dist/%s_%s_checksums.txt\" \\", projectName, version)
|
line(" \"./dist/%s_%s_checksums.txt\" \\", projectName, version)
|
||||||
line(" --clobber")
|
line(" --clobber")
|
||||||
|
|
||||||
// Step 7 – publish.
|
|
||||||
section("Step 7: Publish release (remove draft)")
|
section("Step 7: Publish release (remove draft)")
|
||||||
line("gh release edit %q --draft=false", currentTag)
|
line("gh release edit %q --draft=false", currentTag)
|
||||||
|
|
||||||
|
if wrap {
|
||||||
|
blank()
|
||||||
|
fmt.Println(")")
|
||||||
|
}
|
||||||
blank()
|
blank()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── 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 {
|
func normalizeGitURL(rawURL string) string {
|
||||||
rawURL = strings.TrimSpace(rawURL)
|
rawURL = strings.TrimSpace(rawURL)
|
||||||
rawURL = strings.TrimSuffix(rawURL, ".git")
|
rawURL = strings.TrimSuffix(rawURL, ".git")
|
||||||
@ -500,23 +546,26 @@ func normalizeGitURL(rawURL string) string {
|
|||||||
}
|
}
|
||||||
return rawURL
|
return rawURL
|
||||||
}
|
}
|
||||||
// SCP-style: git@github.com:org/repo
|
|
||||||
if idx := strings.Index(rawURL, "@"); idx >= 0 {
|
if idx := strings.Index(rawURL, "@"); idx >= 0 {
|
||||||
rawURL = rawURL[idx+1:]
|
rawURL = rawURL[idx+1:]
|
||||||
}
|
}
|
||||||
return strings.ReplaceAll(rawURL, ":", "/")
|
return strings.ReplaceAll(rawURL, ":", "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustRun(name string, args ...string) string {
|
func mustRunIn(dir, name string, args ...string) string {
|
||||||
out, err := exec.Command(name, args...).Output()
|
cmd := exec.Command(name, args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
out, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("running %q %v: %v", name, args, err)
|
fatalf("running %q %v in %s: %v", name, args, dir, err)
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(string(out))
|
return strings.TrimSpace(string(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(name string, args ...string) string {
|
func runIn(dir, name string, args ...string) string {
|
||||||
out, _ := exec.Command(name, args...).CombinedOutput()
|
cmd := exec.Command(name, args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
out, _ := cmd.CombinedOutput()
|
||||||
return strings.TrimSpace(string(out))
|
return strings.TrimSpace(string(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user