diff --git a/tools/monorel/go.mod b/tools/monorel/go.mod new file mode 100644 index 0000000..b1686ad --- /dev/null +++ b/tools/monorel/go.mod @@ -0,0 +1,3 @@ +module github.com/therootcompany/golib/tools/monorel + +go 1.22.0 diff --git a/tools/monorel/main.go b/tools/monorel/main.go new file mode 100644 index 0000000..18652da --- /dev/null +++ b/tools/monorel/main.go @@ -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 "/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) +} +