diff --git a/tools/monorel/main.go b/tools/monorel/main.go index 706fa2b..6af6867 100644 --- a/tools/monorel/main.go +++ b/tools/monorel/main.go @@ -23,6 +23,7 @@ package main import ( + "bufio" "flag" "fmt" "go/parser" @@ -57,6 +58,15 @@ type moduleGroup struct { bins []binary } +// releaseStep is one interactive action in the release flow. +type releaseStep struct { + title string // section heading, e.g. "Create git tag" + prompt string // interactive prompt text, e.g. "create tag auth/csvauth/v1.2.5" + display []string // command lines shown to the user before the prompt + skip bool // pre-determined to be skipped (e.g. tag already exists) + run func() error +} + // ── Entry point ──────────────────────────────────────────────────────────── func main() { @@ -102,14 +112,16 @@ func usage() { func runRelease(args []string) { fs := flag.NewFlagSet("monorel release", flag.ExitOnError) - var recursive, all bool + var recursive, all, dryRun, yes bool fs.BoolVar(&recursive, "recursive", false, "find all main packages recursively under each path") fs.BoolVar(&all, "A", false, "include dot/underscore-prefixed directories; warn rather than error on failures") + fs.BoolVar(&dryRun, "dry-run", false, "show each step without running it") + fs.BoolVar(&yes, "yes", false, "run all steps without prompting") fs.Usage = func() { fmt.Fprintln(os.Stderr, "usage: monorel release [options] ...") 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, "Updates .goreleaser.yaml next to each module's go.mod and runs the") + fmt.Fprintln(os.Stderr, "release steps interactively (prompt per step by default).") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Examples:") fmt.Fprintln(os.Stderr, " monorel release . # single binary at module root") @@ -140,11 +152,6 @@ func runRelease(args []string) { 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 i, group := range groups { if i > 0 { fmt.Fprintln(os.Stderr) @@ -152,7 +159,7 @@ func runRelease(args []string) { printGroupHeader(cwd, group) relPath, _ := filepath.Rel(cwd, group.root) relPath = filepath.ToSlash(relPath) - processModule(group, relPath) + processModule(group, relPath, dryRun, yes) } } @@ -943,7 +950,7 @@ func printGroupHeader(cwd string, group *moduleGroup) { // 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) { +func processModule(group *moduleGroup, relPath string, dryRun, yes bool) { modRoot := group.root bins := group.bins @@ -1077,9 +1084,18 @@ func processModule(group *moduleGroup, relPath string) { } headSHA := mustRunIn(modRoot, "git", "rev-parse", "HEAD") - printModuleScript(relPath, projectName, bins, - version, currentTag, prevTag, repoPath, headSHA, - releaseNotes, isPreRelease, needsNewTag, isDirty) + + printModuleHeader(relPath, projectName, bins, + version, currentTag, prevTag, repoPath, isDirty) + + steps := buildModuleSteps( + modRoot, relPath, projectName, bins, + version, currentTag, repoPath, headSHA, + releaseNotes, isPreRelease, needsNewTag, + ) + if err := runSteps(steps, dryRun, yes); err != nil { + fmt.Fprintf(os.Stderr, "monorel: %v\n", err) + } } // ── Version computation ──────────────────────────────────────────────────── @@ -1280,45 +1296,18 @@ func yamlLooksCorrect(content string, bins []binary) bool { return true } -// ── Release script generation ────────────────────────────────────────────── +// ── Release step runner ──────────────────────────────────────────────────── -// 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, - releaseNotes string, - isPreRelease, needsNewTag, isDirty bool, +// printModuleHeader writes the informational header block for one module to +// stdout. It is always shown regardless of dry-run / yes mode. +func printModuleHeader( + relPath, projectName string, bins []binary, + version, currentTag, prevTag, repoPath string, + 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 distDir string - if relPath == "." { - distDir = "./dist" - } else { - 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) @@ -1354,59 +1343,194 @@ func printModuleScript( line("# %-16s (none — first release)", "Previous tag:") } line("# %-16s %s", "Repo:", repoPath) + blank() +} - section("Step 1: Environment variables") - line("export VERSION=%q", version) - - if needsNewTag { - section("Step 2: Create git tag") - line("git tag %q", currentTag) - line("# To undo: git tag -d %q", currentTag) - } - - section("Step 3: Push commits and tags to remote") - line("git push && git push --tags") - - section("Step 4: Build with goreleaser") - line("# release.disable=true in .goreleaser.yaml; goreleaser only builds.") +// buildModuleSteps constructs the ordered list of release steps for one module. +func buildModuleSteps( + modRoot, relPath, projectName string, bins []binary, + version, currentTag, repoPath, headSHA string, + releaseNotes string, + isPreRelease, needsNewTag bool, +) []releaseStep { + distDir := filepath.Join(modRoot, "dist") + var distRelDir string if relPath == "." { - line("goreleaser release --clean --skip=validate,announce") + distRelDir = "./dist" } else { - line("(") - line(" cd %q", relPath) - line(" goreleaser release --clean --skip=validate,announce") - line(")") + distRelDir = relPath + "/dist" } - section("Step 5: Release notes") - line("%s=%s", notesVar, shellSingleQuote(releaseNotes)) - - section("Step 6: 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) + + var steps []releaseStep + + // Step: Create git tag (skipped when tag already exists). + steps = append(steps, releaseStep{ + title: "Create git tag", + prompt: "create tag " + currentTag, + display: []string{ + fmt.Sprintf("git tag %q", currentTag), + fmt.Sprintf("# To undo: git tag -d %q", currentTag), + }, + skip: !needsNewTag, + run: func() error { + return execIn(modRoot, "git", "tag", currentTag) + }, + }) + + // Step: Push commits and tags. + steps = append(steps, releaseStep{ + title: "Push commits and tags to remote", + prompt: "push commits and tags to remote", + display: []string{"git push && git push --tags"}, + run: func() error { + if err := execIn(modRoot, "git", "push"); err != nil { + return err + } + return execIn(modRoot, "git", "push", "--tags") + }, + }) + + // Step: Build with goreleaser. + var gorelDisplay []string + if relPath == "." { + gorelDisplay = []string{"goreleaser release --clean --skip=validate,announce"} + } else { + gorelDisplay = []string{ + "(", + fmt.Sprintf(" cd %q", relPath), + " goreleaser release --clean --skip=validate,announce", + ")", + } + } + steps = append(steps, releaseStep{ + title: "Build with goreleaser", + prompt: "run goreleaser to build assets", + display: gorelDisplay, + run: func() error { + return execInEnv(modRoot, []string{"VERSION=" + version}, + "goreleaser", "release", "--clean", "--skip=validate,announce") + }, + }) + + // Step: Create draft GitHub release. + var ghCreateDisplay []string + ghCreateDisplay = append(ghCreateDisplay, fmt.Sprintf("gh release create %q \\", currentTag)) + ghCreateDisplay = append(ghCreateDisplay, fmt.Sprintf(" --title %q \\", title)) + ghCreateDisplay = append(ghCreateDisplay, fmt.Sprintf(" --notes %s \\", shellSingleQuote(releaseNotes))) if isPreRelease { - line(" --prerelease \\") + ghCreateDisplay = append(ghCreateDisplay, " --prerelease \\") } - line(" --draft \\") - line(" --target %q", headSHA) + ghCreateDisplay = append(ghCreateDisplay, " --draft \\") + ghCreateDisplay = append(ghCreateDisplay, fmt.Sprintf(" --target %q", headSHA)) + steps = append(steps, releaseStep{ + title: "Create draft GitHub release", + prompt: fmt.Sprintf("create draft GitHub release %s", currentTag), + display: ghCreateDisplay, + run: func() error { + ghArgs := []string{"release", "create", currentTag, + "--title", title, + "--notes", releaseNotes, + "--draft", + "--target", headSHA, + } + if isPreRelease { + ghArgs = append(ghArgs, "--prerelease") + } + return execIn(modRoot, "gh", ghArgs...) + }, + }) - section("Step 7: Upload artifacts") - line("gh release upload %q \\", currentTag) + // Step: Upload artifacts (globs expanded at run time after goreleaser). + var uploadDisplay []string + uploadDisplay = append(uploadDisplay, fmt.Sprintf("gh release upload %q \\", currentTag)) for _, bin := range bins { - line(" %s/%s_*.tar.gz \\", distDir, bin.name) - line(" %s/%s_*.tar.zst \\", distDir, bin.name) - line(" %s/%s_*.zip \\", distDir, bin.name) + uploadDisplay = append(uploadDisplay, + fmt.Sprintf(" %s/%s_*.tar.gz \\", distRelDir, bin.name), + fmt.Sprintf(" %s/%s_*.tar.zst \\", distRelDir, bin.name), + fmt.Sprintf(" %s/%s_*.zip \\", distRelDir, bin.name), + ) } - line(" \"%s/%s_%s_checksums.txt\" \\", distDir, projectName, version) - line(" --clobber") + uploadDisplay = append(uploadDisplay, + fmt.Sprintf(" %q \\", distRelDir+"/"+projectName+"_"+version+"_checksums.txt"), + " --clobber", + ) + steps = append(steps, releaseStep{ + title: "Upload artifacts", + prompt: fmt.Sprintf("upload artifacts for %s", currentTag), + display: uploadDisplay, + run: func() error { + ghArgs := []string{"release", "upload", currentTag} + for _, bin := range bins { + for _, pat := range []string{ + bin.name + "_*.tar.gz", + bin.name + "_*.tar.zst", + bin.name + "_*.zip", + } { + matches, _ := filepath.Glob(filepath.Join(distDir, pat)) + ghArgs = append(ghArgs, matches...) + } + } + checksum := filepath.Join(distDir, projectName+"_"+version+"_checksums.txt") + ghArgs = append(ghArgs, checksum, "--clobber") + return execIn(modRoot, "gh", ghArgs...) + }, + }) - section("Step 8: Publish release (remove draft)") - line("gh release edit %q --draft=false", currentTag) + // Step: Publish release. + steps = append(steps, releaseStep{ + title: "Publish release (remove draft)", + prompt: fmt.Sprintf("publish release %s", currentTag), + display: []string{fmt.Sprintf("gh release edit %q --draft=false", currentTag)}, + run: func() error { + return execIn(modRoot, "gh", "release", "edit", currentTag, "--draft=false") + }, + }) - blank() + return steps +} + +// runSteps iterates over steps, displaying each one and either prompting the +// user (default), running without prompting (--yes), or just displaying +// without running (--dry-run). Skipped steps are silently omitted. +func runSteps(steps []releaseStep, dryRun, yes bool) error { + section := func(title string) { + fmt.Printf("\n# ── %s %s\n", title, strings.Repeat("─", max(0, 52-len(title)))) + } + reader := bufio.NewReader(os.Stdin) + + for _, s := range steps { + if s.skip { + continue + } + section(s.title) + for _, l := range s.display { + fmt.Println(" ", l) + } + fmt.Println() + + if dryRun { + fmt.Println("[dry-run] skipping") + continue + } + + if !yes { + fmt.Printf("%s? [Y/n] ", s.prompt) + line, _ := reader.ReadString('\n') + line = strings.ToLower(strings.TrimSpace(line)) + if line == "n" || line == "no" { + fmt.Println("skipped.") + continue + } + } + + if err := s.run(); err != nil { + return fmt.Errorf("%s: %w", s.title, err) + } + } + return nil } // ── Helpers ──────────────────────────────────────────────────────────────── @@ -1477,6 +1601,26 @@ func runPrintIn(dir, name string, args ...string) { } } +// execIn runs name+args in dir, streaming stdout and stderr to the terminal. +// Used by release step run functions. +func execIn(dir, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// execInEnv is like execIn but merges extraEnv into the inherited environment. +func execInEnv(dir string, extraEnv []string, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), extraEnv...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + func fatalf(format string, args ...any) { fmt.Fprintf(os.Stderr, "monorel: error: "+format+"\n", args...) os.Exit(1)