refactor(monorel): replace bash script output with interactive step runner

Replace printModuleScript (which emitted a bash script to stdout) with an
interactive step runner for the release subcommand:

- Each step shows the command(s) it will run, then prompts [Y/n]
- --dry-run: show steps without prompting or running (replaces old default)
- --yes: run all steps without prompting (happy-path automation)

New types/functions:
  releaseStep       — title, prompt, display lines, skip flag, run func
  printModuleHeader — extracted header/info block (always shown)
  buildModuleSteps  — constructs the ordered step list for one module
  runSteps          — executes steps per dryRun/yes flags
  execIn            — runs a command streaming to the terminal
  execInEnv         — like execIn with extra environment variables

Goreleaser archive globs are expanded at step-run time (after goreleaser
has built the dist/ directory) rather than being passed as shell globs.
The gh release create --notes flag receives the notes string directly
instead of via a shell variable.
This commit is contained in:
AJ ONeal 2026-03-01 00:51:33 -07:00
parent e6f82ed91b
commit b6fd4072bf
No known key found for this signature in database

View File

@ -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] <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, "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)